telemetry-dev 0.2.9__tar.gz → 0.2.11__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 (21) hide show
  1. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/PKG-INFO +95 -6
  2. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/README.md +93 -5
  3. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/pyproject.toml +2 -1
  4. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/pyproject.toml.orig +2 -1
  5. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_client.py +10 -70
  6. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_config.py +6 -2
  7. telemetry_dev-0.2.11/src/telemetry_dev/_export.py +100 -0
  8. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_metrics.py +12 -9
  9. telemetry_dev-0.2.11/src/telemetry_dev/_serialize.py +220 -0
  10. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_spans.py +37 -12
  11. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/otel.py +6 -0
  12. telemetry_dev-0.2.9/src/telemetry_dev/_serialize.py +0 -103
  13. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/LICENSE +0 -0
  14. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/__init__.py +0 -0
  15. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_capture.py +0 -0
  16. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_context.py +0 -0
  17. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_logs.py +0 -0
  18. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_observe.py +0 -0
  19. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_processor.py +0 -0
  20. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_semconv.py +0 -0
  21. {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telemetry-dev
3
- Version: 0.2.9
3
+ Version: 0.2.11
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
@@ -17,6 +17,7 @@ Classifier: Typing :: Typed
17
17
  Requires-Dist: opentelemetry-api>=1.39.0,<2
18
18
  Requires-Dist: opentelemetry-sdk>=1.39.0,<2
19
19
  Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.39.0,<2
20
+ Requires-Dist: rfc8785>=0.1.4
20
21
  Requires-Python: >=3.10
21
22
  Project-URL: Homepage, https://telemetry.dev
22
23
  Project-URL: Repository, https://github.com/telemetry-dev/telemetry
@@ -28,6 +29,8 @@ telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and m
28
29
  ergonomic functions over OTel spans (`gen_ai.*` semantic conventions), exported as OTLP
29
30
  protobuf to the telemetry.dev ingest.
30
31
 
32
+ Docs: https://telemetry.dev/docs/sdk/python
33
+
31
34
  ## Install
32
35
 
33
36
  ```sh
@@ -100,6 +103,7 @@ later `init()` generates a new ID. It is not persisted across processes.
100
103
  | `@observe` / `@observe(name=, type=, capture_input=, capture_output=, attributes=)` | Wrap a sync/async function (or generator) in a span. Arguments become `input` (param-name dict, `self`/`cls` dropped), the return value becomes `output`, exceptions are captured and re-raised. |
101
104
  | `start_span(name, *, type="span", ...) -> SpanHandle` | Start a span. `with` activates it in the current context; without `with` it is a detached handle you must `.end()`. |
102
105
  | `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. |
106
+ | `SpanHandle.serialize_content(value, key) -> str \| None` | Prepare content an integration writes outside `input` and `output`, such as an error message or a raw attribute, the way `input` and `output` are prepared: the configured `mask` with `key`, JSON serialization (strings pass through), then truncation to `max_attribute_length`. Returns `None` when the mask raises (reported through `on_error`) or the value cannot be serialized; write nothing then. Does not check `capture_input` / `capture_output`. Integrations that support older cores should treat a missing method as a configured mask. |
103
107
  | `update_current_span(**fields)` | Apply the update field set to the currently active span (no-op without one). |
104
108
  | `propagate_attributes(*, user_id=, session_id=, metadata=)` | Context manager stamping `user.id` / `gen_ai.conversation.id` / `td.metadata.*` on every span and log record started inside (threads/asyncio included via contextvars). |
105
109
  | `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`). |
@@ -148,29 +152,48 @@ the core SDK with provider integrations to enable the new metric.
148
152
  `SpanContext`), `start_time`, and per-call `capture_input` / `capture_output` overrides;
149
153
  `end()` additionally accepts `end_time`.
150
154
 
155
+ Core SDK error recording keeps failure status, error type, and an exception event. It includes
156
+ `exception.message` and `exception.stacktrace` only when both effective `capture_input` and
157
+ `capture_output` are true, including per-span overrides: exceptions can echo either inputs or
158
+ outputs. The policy is per span: when an error propagates to an enclosing span whose capture is on,
159
+ that span records its message and stack. Included fields pass through `mask` with their exception
160
+ attribute keys, then serialization and truncation. A truncated `exception.stacktrace` keeps its
161
+ end, where Python normally puts the innermost frame and the exception line. Mask and serialization failures drop the affected field and report generic errors
162
+ to `on_error` and SDK diagnostics without the original message, traceback, or exception chain.
163
+ The original application error is raised unchanged. These controls do not scrub error type names,
164
+ metadata, identifiers, resource attributes, or events written directly through raw OpenTelemetry APIs.
165
+
151
166
  ### init() options
152
167
 
153
168
  | Option | Default | Purpose |
154
169
  | --- | --- | --- |
155
170
  | `api_key`, `base_url`, `environment`, `service_name` | env vars | Connection + resource settings. |
156
171
  | `enabled` | `True` | `False` = hard kill switch (tests). |
172
+ | `session_mode` | `"explicit"` | `"process"` groups otherwise uncorrelated spans and logs under one generated session ID per `init()` (see [Environment variables](#environment-variables)). Other values call `on_error` and install a disabled client. |
157
173
  | `register_global` | `False` | Also register the tracer provider globally. Applies a default export filter (only `telemetry_dev`-scoped spans are exported); pass `span_filter=lambda s: True` to export everything. |
158
174
  | `export_mode` | `"batched"` | `"immediate"` exports synchronously per span/log (serverless). |
159
175
  | `log_level` | `"warn"` | SDK diagnostics level: `debug`/`info`/`warn`/`error`/`silent`. |
160
176
  | `capture_input`, `capture_output` | `True` | Global content-capture defaults. |
161
- | `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. |
177
+ | `mask` | `None` | `Callable[[Any, MaskContext], Any]` redaction hook, runs before JSON serialization on input/output/log messages and `SpanHandle.serialize_content` (`MaskContext.key` is the attribute being written). Not applied to correlation identifiers. |
162
178
  | `max_attribute_length` | `65536` | Non-negative integer per-content-attribute cap. Invalid values call `on_error`, do not raise into application code, and install a disabled client; truncated values use the ASCII `...[truncated]` marker, or its prefix when the cap is shorter than the marker. The cap also applies, with the marker, to string metadata and log attributes; any other string attribute is cut at the cap without a marker, except that a cap of `0` leaves those at 65536. |
163
179
  | `span_filter` | `None` | Export predicate `Callable[[ReadableSpan], bool]`. |
164
180
  | `sampler` | OTel environment configuration | OTel `Sampler` that overrides environment settings. Session roots use its root policy. Real parents keep their sampling decisions. |
165
- | `on_error` | `None` | Receives internal SDK errors; repeated instrumentation failures from one stream may be coalesced into one report. The SDK never raises them into application code. |
181
+ | `on_error` | `None` | Receives internal SDK errors; repeated instrumentation failures from one stream may be coalesced into one report. The SDK never raises them into application code. A series of failed exports, for example during an ingest outage, is one report to `on_error` and the `telemetry_dev` logger. After an export reaches the ingest, the next failure is a new report. |
166
182
  | `disable_atexit` | `False` | Skip the automatic atexit shutdown. |
167
183
  | `timeout` | `10.0` | OTLP HTTP timeout in seconds. |
168
184
  | `span_exporter`, `log_exporter`, `metric_reader` | `None` | Test seams / offline mode; any of them enables the client without an API key. |
169
185
 
170
186
  `capture_input=False` and `capture_output=False` omit captured content. Provider integrations may
171
187
  still record non-content metadata such as response IDs and models, finish reasons, usage, timing,
172
- and errors. Capture flags do not gate stop sequences, tool descriptions and definitions (`gen_ai.tool.description`, `gen_ai.tool.definitions`), caller-supplied metadata or raw attributes,
173
- or exception messages and stack traces; redact those separately when needed.
188
+ and error types. Capture flags do not gate stop sequences, tool descriptions and definitions
189
+ (`gen_ai.tool.description`, `gen_ai.tool.definitions`), caller-supplied metadata or raw attributes;
190
+ redact those separately when needed.
191
+
192
+ `mask` receives input and output content, system instructions, log messages, captured exception
193
+ messages and stack traces, and content that
194
+ integrations pass to `SpanHandle.serialize_content`. `MaskContext.key` names the attribute being
195
+ written. It does not receive span or propagated `metadata`, raw `attributes`, or log attributes;
196
+ redact those separately. Log masking is independent of span capture flags.
174
197
 
175
198
  Session roots share trace IDs from the API key and session ID. The hash matches the TypeScript SDK.
176
199
  The SDK uses the configured OTel sampling policy, not the synthetic parent's flags.
@@ -244,7 +267,7 @@ from telemetry_dev import TelemetrySpanProcessor
244
267
  | `metrics` | `True` | Auto-record GenAI duration/token histograms for exported spans when an API key is available. |
245
268
  | `service_name` | `OTEL_SERVICE_NAME` or `unknown_service` | `service.name` on the metrics resource. |
246
269
  | `environment` | `TELEMETRY_DEV_ENVIRONMENT` or `production` | `deployment.environment.name` on the metrics resource. |
247
- | `on_error` | `None` | Receives internal processor errors; errors are never raised into your code. |
270
+ | `on_error` | `None` | Receives export errors of the telemetry.dev exporters and internal processor errors; errors are never raised into your code. A series of failed exports is one report. After an export reaches the ingest, the next failure is a new report. |
248
271
  | `span_exporter` | `None` | Advanced/test seam replacing the telemetry.dev OTLP trace exporter. |
249
272
 
250
273
  Without an API key and without `span_exporter`, `TelemetrySpanProcessor()` is an inert no-op:
@@ -252,6 +275,68 @@ safe to attach unconditionally, with only a debug log. Auto-metrics are on by de
252
275
  GenAI spans, use the `service_name` / `environment` resource settings above, and are skipped
253
276
  without an API key.
254
277
 
278
+ ### Native Claude SDK spans
279
+
280
+ The Python `anthropic` SDK 1.14.0 and later emits OpenTelemetry spans natively. The Claude SDK uses the global tracer provider of the application.
281
+ To send these spans to telemetry.dev, register a global tracer provider with `TelemetrySpanProcessor`:
282
+
283
+ ```sh
284
+ pip install telemetry-dev "anthropic>=1.14.0"
285
+ ```
286
+
287
+ ```python
288
+ from anthropic import Anthropic
289
+ from opentelemetry import trace
290
+ from opentelemetry.sdk.trace import TracerProvider
291
+ from telemetry_dev.otel import TelemetrySpanProcessor
292
+
293
+ provider = TracerProvider()
294
+ provider.add_span_processor(TelemetrySpanProcessor())
295
+ trace.set_tracer_provider(provider)
296
+
297
+ client = Anthropic()
298
+ client.messages.create(
299
+ model="claude-sonnet-4-6",
300
+ max_tokens=256,
301
+ messages=[{"role": "user", "content": "Say hello."}],
302
+ )
303
+
304
+ provider.shutdown()
305
+ ```
306
+
307
+ If you use `telemetry_dev.init(register_global=True)`, also pass `span_filter=lambda span: True`. The default filter of that mode exports only the spans of this SDK.
308
+
309
+ By default, the spans contain metadata only. To also send prompts and responses, set `ANTHROPIC_OPEN_TELEMETRY_TRACES_CONTENT_MODE=content`. The client reads this variable one time, when you make the client.
310
+
311
+ Do not use the native spans and `telemetry-dev-anthropic` together. If you use the two, each request has two spans.
312
+
313
+ You can also send the native spans without this package. Use an OTLP/HTTP protobuf exporter:
314
+
315
+ ```sh
316
+ pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http
317
+ ```
318
+
319
+ ```python
320
+ import os
321
+
322
+ from opentelemetry import trace
323
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
324
+ from opentelemetry.sdk.trace import TracerProvider
325
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
326
+
327
+ exporter = OTLPSpanExporter(
328
+ endpoint="https://ingest.telemetry.dev/v1/traces",
329
+ headers={"Authorization": f"Bearer {os.environ['TELEMETRY_DEV_API_KEY']}"},
330
+ )
331
+ provider = TracerProvider()
332
+ provider.add_span_processor(BatchSpanProcessor(exporter))
333
+ trace.set_tracer_provider(provider)
334
+ ```
335
+
336
+ The Claude API can also send server-side spans to telemetry.dev through a Console destination. Set the endpoint to `https://ingest.telemetry.dev`. Set the `Authorization` header to `Bearer <telemetry.dev API key>`. For more information, refer to [Claude Platform](https://telemetry.dev/docs/integrations/claude-platform).
337
+
338
+ When the SDK and the Claude API send the spans of a request to the same telemetry.dev project, telemetry.dev counts the tokens of the request one time, from the API span. The SDK span stays in the trace with no tokens. telemetry.dev matches the two spans by the request ID or the response ID, so the API span does not have to be a child of the SDK span. If the Console destination uses an API key that is scoped to one environment, its API spans take over only the SDK spans of that environment.
339
+
255
340
  ## Limitations
256
341
 
257
342
  - `register_global=True` cannot be undone on `shutdown()`: OpenTelemetry Python has no public
@@ -274,3 +359,7 @@ uv build # build wheel + sdist
274
359
 
275
360
  `examples/quickstart.py` is runnable against a real ingest:
276
361
  `TELEMETRY_DEV_API_KEY=td_live_... uv run examples/quickstart.py`.
362
+
363
+ ## License
364
+
365
+ MIT. See [LICENSE](https://github.com/telemetry-dev/telemetry/blob/main/sdks/python/LICENSE).
@@ -4,6 +4,8 @@ telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and m
4
4
  ergonomic functions over OTel spans (`gen_ai.*` semantic conventions), exported as OTLP
5
5
  protobuf to the telemetry.dev ingest.
6
6
 
7
+ Docs: https://telemetry.dev/docs/sdk/python
8
+
7
9
  ## Install
8
10
 
9
11
  ```sh
@@ -76,6 +78,7 @@ later `init()` generates a new ID. It is not persisted across processes.
76
78
  | `@observe` / `@observe(name=, type=, capture_input=, capture_output=, attributes=)` | Wrap a sync/async function (or generator) in a span. Arguments become `input` (param-name dict, `self`/`cls` dropped), the return value becomes `output`, exceptions are captured and re-raised. |
77
79
  | `start_span(name, *, type="span", ...) -> SpanHandle` | Start a span. `with` activates it in the current context; without `with` it is a detached handle you must `.end()`. |
78
80
  | `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. |
81
+ | `SpanHandle.serialize_content(value, key) -> str \| None` | Prepare content an integration writes outside `input` and `output`, such as an error message or a raw attribute, the way `input` and `output` are prepared: the configured `mask` with `key`, JSON serialization (strings pass through), then truncation to `max_attribute_length`. Returns `None` when the mask raises (reported through `on_error`) or the value cannot be serialized; write nothing then. Does not check `capture_input` / `capture_output`. Integrations that support older cores should treat a missing method as a configured mask. |
79
82
  | `update_current_span(**fields)` | Apply the update field set to the currently active span (no-op without one). |
80
83
  | `propagate_attributes(*, user_id=, session_id=, metadata=)` | Context manager stamping `user.id` / `gen_ai.conversation.id` / `td.metadata.*` on every span and log record started inside (threads/asyncio included via contextvars). |
81
84
  | `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`). |
@@ -124,29 +127,48 @@ the core SDK with provider integrations to enable the new metric.
124
127
  `SpanContext`), `start_time`, and per-call `capture_input` / `capture_output` overrides;
125
128
  `end()` additionally accepts `end_time`.
126
129
 
130
+ Core SDK error recording keeps failure status, error type, and an exception event. It includes
131
+ `exception.message` and `exception.stacktrace` only when both effective `capture_input` and
132
+ `capture_output` are true, including per-span overrides: exceptions can echo either inputs or
133
+ outputs. The policy is per span: when an error propagates to an enclosing span whose capture is on,
134
+ that span records its message and stack. Included fields pass through `mask` with their exception
135
+ attribute keys, then serialization and truncation. A truncated `exception.stacktrace` keeps its
136
+ end, where Python normally puts the innermost frame and the exception line. Mask and serialization failures drop the affected field and report generic errors
137
+ to `on_error` and SDK diagnostics without the original message, traceback, or exception chain.
138
+ The original application error is raised unchanged. These controls do not scrub error type names,
139
+ metadata, identifiers, resource attributes, or events written directly through raw OpenTelemetry APIs.
140
+
127
141
  ### init() options
128
142
 
129
143
  | Option | Default | Purpose |
130
144
  | --- | --- | --- |
131
145
  | `api_key`, `base_url`, `environment`, `service_name` | env vars | Connection + resource settings. |
132
146
  | `enabled` | `True` | `False` = hard kill switch (tests). |
147
+ | `session_mode` | `"explicit"` | `"process"` groups otherwise uncorrelated spans and logs under one generated session ID per `init()` (see [Environment variables](#environment-variables)). Other values call `on_error` and install a disabled client. |
133
148
  | `register_global` | `False` | Also register the tracer provider globally. Applies a default export filter (only `telemetry_dev`-scoped spans are exported); pass `span_filter=lambda s: True` to export everything. |
134
149
  | `export_mode` | `"batched"` | `"immediate"` exports synchronously per span/log (serverless). |
135
150
  | `log_level` | `"warn"` | SDK diagnostics level: `debug`/`info`/`warn`/`error`/`silent`. |
136
151
  | `capture_input`, `capture_output` | `True` | Global content-capture defaults. |
137
- | `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. |
152
+ | `mask` | `None` | `Callable[[Any, MaskContext], Any]` redaction hook, runs before JSON serialization on input/output/log messages and `SpanHandle.serialize_content` (`MaskContext.key` is the attribute being written). Not applied to correlation identifiers. |
138
153
  | `max_attribute_length` | `65536` | Non-negative integer per-content-attribute cap. Invalid values call `on_error`, do not raise into application code, and install a disabled client; truncated values use the ASCII `...[truncated]` marker, or its prefix when the cap is shorter than the marker. The cap also applies, with the marker, to string metadata and log attributes; any other string attribute is cut at the cap without a marker, except that a cap of `0` leaves those at 65536. |
139
154
  | `span_filter` | `None` | Export predicate `Callable[[ReadableSpan], bool]`. |
140
155
  | `sampler` | OTel environment configuration | OTel `Sampler` that overrides environment settings. Session roots use its root policy. Real parents keep their sampling decisions. |
141
- | `on_error` | `None` | Receives internal SDK errors; repeated instrumentation failures from one stream may be coalesced into one report. The SDK never raises them into application code. |
156
+ | `on_error` | `None` | Receives internal SDK errors; repeated instrumentation failures from one stream may be coalesced into one report. The SDK never raises them into application code. A series of failed exports, for example during an ingest outage, is one report to `on_error` and the `telemetry_dev` logger. After an export reaches the ingest, the next failure is a new report. |
142
157
  | `disable_atexit` | `False` | Skip the automatic atexit shutdown. |
143
158
  | `timeout` | `10.0` | OTLP HTTP timeout in seconds. |
144
159
  | `span_exporter`, `log_exporter`, `metric_reader` | `None` | Test seams / offline mode; any of them enables the client without an API key. |
145
160
 
146
161
  `capture_input=False` and `capture_output=False` omit captured content. Provider integrations may
147
162
  still record non-content metadata such as response IDs and models, finish reasons, usage, timing,
148
- and errors. Capture flags do not gate stop sequences, tool descriptions and definitions (`gen_ai.tool.description`, `gen_ai.tool.definitions`), caller-supplied metadata or raw attributes,
149
- or exception messages and stack traces; redact those separately when needed.
163
+ and error types. Capture flags do not gate stop sequences, tool descriptions and definitions
164
+ (`gen_ai.tool.description`, `gen_ai.tool.definitions`), caller-supplied metadata or raw attributes;
165
+ redact those separately when needed.
166
+
167
+ `mask` receives input and output content, system instructions, log messages, captured exception
168
+ messages and stack traces, and content that
169
+ integrations pass to `SpanHandle.serialize_content`. `MaskContext.key` names the attribute being
170
+ written. It does not receive span or propagated `metadata`, raw `attributes`, or log attributes;
171
+ redact those separately. Log masking is independent of span capture flags.
150
172
 
151
173
  Session roots share trace IDs from the API key and session ID. The hash matches the TypeScript SDK.
152
174
  The SDK uses the configured OTel sampling policy, not the synthetic parent's flags.
@@ -220,7 +242,7 @@ from telemetry_dev import TelemetrySpanProcessor
220
242
  | `metrics` | `True` | Auto-record GenAI duration/token histograms for exported spans when an API key is available. |
221
243
  | `service_name` | `OTEL_SERVICE_NAME` or `unknown_service` | `service.name` on the metrics resource. |
222
244
  | `environment` | `TELEMETRY_DEV_ENVIRONMENT` or `production` | `deployment.environment.name` on the metrics resource. |
223
- | `on_error` | `None` | Receives internal processor errors; errors are never raised into your code. |
245
+ | `on_error` | `None` | Receives export errors of the telemetry.dev exporters and internal processor errors; errors are never raised into your code. A series of failed exports is one report. After an export reaches the ingest, the next failure is a new report. |
224
246
  | `span_exporter` | `None` | Advanced/test seam replacing the telemetry.dev OTLP trace exporter. |
225
247
 
226
248
  Without an API key and without `span_exporter`, `TelemetrySpanProcessor()` is an inert no-op:
@@ -228,6 +250,68 @@ safe to attach unconditionally, with only a debug log. Auto-metrics are on by de
228
250
  GenAI spans, use the `service_name` / `environment` resource settings above, and are skipped
229
251
  without an API key.
230
252
 
253
+ ### Native Claude SDK spans
254
+
255
+ The Python `anthropic` SDK 1.14.0 and later emits OpenTelemetry spans natively. The Claude SDK uses the global tracer provider of the application.
256
+ To send these spans to telemetry.dev, register a global tracer provider with `TelemetrySpanProcessor`:
257
+
258
+ ```sh
259
+ pip install telemetry-dev "anthropic>=1.14.0"
260
+ ```
261
+
262
+ ```python
263
+ from anthropic import Anthropic
264
+ from opentelemetry import trace
265
+ from opentelemetry.sdk.trace import TracerProvider
266
+ from telemetry_dev.otel import TelemetrySpanProcessor
267
+
268
+ provider = TracerProvider()
269
+ provider.add_span_processor(TelemetrySpanProcessor())
270
+ trace.set_tracer_provider(provider)
271
+
272
+ client = Anthropic()
273
+ client.messages.create(
274
+ model="claude-sonnet-4-6",
275
+ max_tokens=256,
276
+ messages=[{"role": "user", "content": "Say hello."}],
277
+ )
278
+
279
+ provider.shutdown()
280
+ ```
281
+
282
+ If you use `telemetry_dev.init(register_global=True)`, also pass `span_filter=lambda span: True`. The default filter of that mode exports only the spans of this SDK.
283
+
284
+ By default, the spans contain metadata only. To also send prompts and responses, set `ANTHROPIC_OPEN_TELEMETRY_TRACES_CONTENT_MODE=content`. The client reads this variable one time, when you make the client.
285
+
286
+ Do not use the native spans and `telemetry-dev-anthropic` together. If you use the two, each request has two spans.
287
+
288
+ You can also send the native spans without this package. Use an OTLP/HTTP protobuf exporter:
289
+
290
+ ```sh
291
+ pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http
292
+ ```
293
+
294
+ ```python
295
+ import os
296
+
297
+ from opentelemetry import trace
298
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
299
+ from opentelemetry.sdk.trace import TracerProvider
300
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
301
+
302
+ exporter = OTLPSpanExporter(
303
+ endpoint="https://ingest.telemetry.dev/v1/traces",
304
+ headers={"Authorization": f"Bearer {os.environ['TELEMETRY_DEV_API_KEY']}"},
305
+ )
306
+ provider = TracerProvider()
307
+ provider.add_span_processor(BatchSpanProcessor(exporter))
308
+ trace.set_tracer_provider(provider)
309
+ ```
310
+
311
+ The Claude API can also send server-side spans to telemetry.dev through a Console destination. Set the endpoint to `https://ingest.telemetry.dev`. Set the `Authorization` header to `Bearer <telemetry.dev API key>`. For more information, refer to [Claude Platform](https://telemetry.dev/docs/integrations/claude-platform).
312
+
313
+ When the SDK and the Claude API send the spans of a request to the same telemetry.dev project, telemetry.dev counts the tokens of the request one time, from the API span. The SDK span stays in the trace with no tokens. telemetry.dev matches the two spans by the request ID or the response ID, so the API span does not have to be a child of the SDK span. If the Console destination uses an API key that is scoped to one environment, its API spans take over only the SDK spans of that environment.
314
+
231
315
  ## Limitations
232
316
 
233
317
  - `register_global=True` cannot be undone on `shutdown()`: OpenTelemetry Python has no public
@@ -250,3 +334,7 @@ uv build # build wheel + sdist
250
334
 
251
335
  `examples/quickstart.py` is runnable against a real ingest:
252
336
  `TELEMETRY_DEV_API_KEY=td_live_... uv run examples/quickstart.py`.
337
+
338
+ ## License
339
+
340
+ MIT. See [LICENSE](https://github.com/telemetry-dev/telemetry/blob/main/sdks/python/LICENSE).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev"
3
- version = "0.2.9"
3
+ version = "0.2.11"
4
4
  description = "telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -28,6 +28,7 @@ dependencies = [
28
28
  "opentelemetry-api>=1.39.0,<2",
29
29
  "opentelemetry-sdk>=1.39.0,<2",
30
30
  "opentelemetry-exporter-otlp-proto-http>=1.39.0,<2",
31
+ "rfc8785>=0.1.4",
31
32
  ]
32
33
 
33
34
  [[project.authors]]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev"
3
- version = "0.2.9"
3
+ version = "0.2.11"
4
4
  description = "telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -22,6 +22,7 @@ dependencies = [
22
22
  "opentelemetry-api>=1.39.0,<2",
23
23
  "opentelemetry-sdk>=1.39.0,<2",
24
24
  "opentelemetry-exporter-otlp-proto-http>=1.39.0,<2",
25
+ "rfc8785>=0.1.4",
25
26
  ]
26
27
 
27
28
  [project.urls]
@@ -15,7 +15,6 @@ from opentelemetry.sdk._logs import LoggerProvider
15
15
  from opentelemetry.sdk._logs.export import (
16
16
  BatchLogRecordProcessor,
17
17
  LogRecordExporter,
18
- LogRecordExportResult,
19
18
  SimpleLogRecordProcessor,
20
19
  )
21
20
  from opentelemetry.sdk.metrics import Histogram, MeterProvider
@@ -26,7 +25,7 @@ from opentelemetry.sdk.metrics.export import (
26
25
  )
27
26
  from opentelemetry.sdk.resources import Resource
28
27
  from opentelemetry.sdk.trace import ReadableSpan, SpanLimits, TracerProvider
29
- from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
28
+ from opentelemetry.sdk.trace.export import SpanExporter
30
29
  from opentelemetry.sdk.trace.sampling import Sampler
31
30
  from opentelemetry.trace import Tracer
32
31
 
@@ -41,6 +40,7 @@ from ._config import (
41
40
  resolve_config,
42
41
  )
43
42
  from ._context import SessionSampler
43
+ from ._export import ExportFailures, ReportingLogExporter, ReportingSpanExporter
44
44
  from ._metrics import (
45
45
  GuardedOTLPMetricExporter,
46
46
  MetricsRecorder,
@@ -60,68 +60,6 @@ def _default_scope_filter(span: ReadableSpan) -> bool:
60
60
  return scope is not None and scope.name == SCOPE_NAME
61
61
 
62
62
 
63
- class _ReportingSpanExporter(SpanExporter):
64
- """Surfaces transport failures to on_error (TS parity: exporters route errors to onError,
65
- the stock OTLP exporter only logs them)."""
66
-
67
- def __init__(
68
- self, inner: SpanExporter, on_error: Callable[[BaseException], None] | None
69
- ) -> None:
70
- self._inner = inner
71
- self._on_error = on_error
72
-
73
- def export(self, spans: Any) -> SpanExportResult:
74
- try:
75
- result = self._inner.export(spans)
76
- except BaseException as exc:
77
- report_error(self._on_error, "span export failed", exc)
78
- return SpanExportResult.FAILURE
79
- if result is SpanExportResult.FAILURE:
80
- report_error(
81
- self._on_error,
82
- "span export failed",
83
- RuntimeError("OTLP span export failed (check the API key and ingest URL)"),
84
- )
85
- return result
86
-
87
- def force_flush(self, timeout_millis: int = 30000) -> bool:
88
- return self._inner.force_flush(timeout_millis)
89
-
90
- def shutdown(self) -> None:
91
- self._inner.shutdown()
92
-
93
-
94
- class _ReportingLogExporter(LogRecordExporter):
95
- """Log-exporter twin of _ReportingSpanExporter."""
96
-
97
- def __init__(
98
- self, inner: LogRecordExporter, on_error: Callable[[BaseException], None] | None
99
- ) -> None:
100
- self._inner = inner
101
- self._on_error = on_error
102
-
103
- def export(self, batch: Any) -> LogRecordExportResult:
104
- try:
105
- result = self._inner.export(batch)
106
- except BaseException as exc:
107
- report_error(self._on_error, "log export failed", exc)
108
- return LogRecordExportResult.FAILURE
109
- if result is LogRecordExportResult.FAILURE:
110
- report_error(
111
- self._on_error,
112
- "log export failed",
113
- RuntimeError("OTLP log export failed (check the API key and ingest URL)"),
114
- )
115
- return result
116
-
117
- def force_flush(self, timeout_millis: int = 30000) -> bool:
118
- inner_flush = getattr(self._inner, "force_flush", None)
119
- return inner_flush(timeout_millis) if inner_flush is not None else True
120
-
121
- def shutdown(self) -> None:
122
- self._inner.shutdown()
123
-
124
-
125
63
  class Client:
126
64
  """Holds the telemetry pipelines for one init() call. Obtain via telemetry_dev.init()."""
127
65
 
@@ -195,6 +133,7 @@ class Client:
195
133
  ),
196
134
  )
197
135
  self._tracer_provider.sampler = SessionSampler(self._tracer_provider.sampler)
136
+ failures = ExportFailures(on_error)
198
137
 
199
138
  # Without an api key (enabled via a test seam), never construct real network
200
139
  # exporters — they would POST to the ingest with a bogus Authorization header.
@@ -206,7 +145,7 @@ class Client:
206
145
  compression=Compression.Gzip,
207
146
  timeout=timeout,
208
147
  preferred_temporality={Histogram: AggregationTemporality.DELTA},
209
- on_error=on_error,
148
+ failures=failures,
210
149
  )
211
150
  metric_exporter.output_chunks = self._output_chunks
212
151
  metric_reader = PeriodicExportingMetricReader(
@@ -221,14 +160,14 @@ class Client:
221
160
  )
222
161
 
223
162
  if span_exporter is None:
224
- span_exporter = _ReportingSpanExporter(
163
+ span_exporter = ReportingSpanExporter(
225
164
  OTLPSpanExporter(
226
165
  endpoint=f"{config.base_url}/v1/traces",
227
166
  headers=headers,
228
167
  compression=Compression.Gzip,
229
168
  timeout=timeout,
230
169
  ),
231
- on_error,
170
+ failures,
232
171
  )
233
172
  if span_filter is None and register_global:
234
173
  span_filter = _default_scope_filter
@@ -243,14 +182,14 @@ class Client:
243
182
  self.tracer = self._tracer_provider.get_tracer(SCOPE_NAME, SDK_VERSION)
244
183
 
245
184
  if log_exporter is None and config.api_key is not None:
246
- log_exporter = _ReportingLogExporter(
185
+ log_exporter = ReportingLogExporter(
247
186
  OTLPLogExporter(
248
187
  endpoint=f"{config.base_url}/v1/logs",
249
188
  headers=headers,
250
189
  compression=Compression.Gzip,
251
190
  timeout=timeout,
252
191
  ),
253
- on_error,
192
+ failures,
254
193
  )
255
194
  if log_exporter is not None:
256
195
  self._logger_provider = LoggerProvider(resource=resource, shutdown_on_exit=False)
@@ -277,13 +216,14 @@ class Client:
277
216
  def report(self, message: str, exc: BaseException) -> None:
278
217
  report_error(self.on_error, message, exc)
279
218
 
280
- def serialize(self, value: Any, key: str) -> str | None:
219
+ def serialize(self, value: Any, key: str, *, keep_end: bool = False) -> str | None:
281
220
  return serialize_content(
282
221
  value,
283
222
  key=key,
284
223
  mask=self.mask,
285
224
  max_len=self.max_attribute_length,
286
225
  on_error=self.on_error,
226
+ keep_end=keep_end,
287
227
  )
288
228
 
289
229
  def flush(self, timeout_s: float = 10.0) -> None:
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import logging
4
4
  import os
5
5
  import re
6
+ import traceback
6
7
  from collections.abc import Callable
7
8
  from dataclasses import dataclass
8
9
  from typing import Literal
@@ -74,9 +75,12 @@ def report_error(
74
75
  exc: BaseException,
75
76
  ) -> None:
76
77
  """Route an internal SDK error to the on_error hook + leveled logger; never raises."""
78
+ # Snapshot before callbacks can re-raise exc and attach application exception chains.
79
+ if logger.isEnabledFor(logging.ERROR):
80
+ trace = "".join(traceback.format_exception(exc)).rstrip()
81
+ logger.error("telemetry-dev: %s\n%s", message, trace)
77
82
  if on_error is not None:
78
83
  try:
79
84
  on_error(exc)
80
85
  except BaseException:
81
- logger.debug("telemetry-dev on_error hook raised", exc_info=True)
82
- logger.error("telemetry-dev: %s", message, exc_info=exc)
86
+ logger.debug("telemetry-dev on_error hook raised")
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from collections.abc import Callable
5
+ from typing import Any
6
+
7
+ from opentelemetry.sdk._logs.export import LogRecordExporter, LogRecordExportResult
8
+ from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
9
+
10
+ from ._config import report_error
11
+
12
+
13
+ class ExportFailures:
14
+ """Reports the first failed export of a series to on_error and the SDK logger.
15
+
16
+ The series stops when an export reaches the ingest, and the next failed export starts a new
17
+ series. During an outage, each batch is a failed export, and one report for each batch is too
18
+ many. The exporters of one client or TelemetrySpanProcessor use one instance, so an outage
19
+ that stops traces, logs, and metrics is one report. Exporters run on background threads, thus
20
+ report() and clear() hold a lock.
21
+ """
22
+
23
+ def __init__(self, on_error: Callable[[BaseException], None] | None) -> None:
24
+ self._on_error = on_error
25
+ self._lock = threading.Lock()
26
+ self._reported = False
27
+
28
+ def report(self, message: str, exc: BaseException) -> None:
29
+ with self._lock:
30
+ if self._reported:
31
+ return
32
+ self._reported = True
33
+ # report_error runs outside the lock. Thus a slow on_error does not stop other exporters,
34
+ # and an on_error that sends telemetry does not deadlock.
35
+ report_error(self._on_error, message, exc)
36
+
37
+ def clear(self) -> None:
38
+ """Records that an export reached the ingest, so that report() sends the next failure."""
39
+ with self._lock:
40
+ self._reported = False
41
+
42
+
43
+ class ReportingSpanExporter(SpanExporter):
44
+ """Reports export failures through its ExportFailures (TS parity). The OTLP exporter of
45
+ OpenTelemetry only logs them."""
46
+
47
+ def __init__(self, inner: SpanExporter, failures: ExportFailures) -> None:
48
+ self._inner = inner
49
+ self._failures = failures
50
+
51
+ def export(self, spans: Any) -> SpanExportResult:
52
+ try:
53
+ result = self._inner.export(spans)
54
+ except BaseException as exc:
55
+ self._failures.report("span export failed", exc)
56
+ return SpanExportResult.FAILURE
57
+ if result is SpanExportResult.FAILURE:
58
+ self._failures.report(
59
+ "span export failed",
60
+ RuntimeError("OTLP span export failed (check the API key and ingest URL)"),
61
+ )
62
+ else:
63
+ self._failures.clear()
64
+ return result
65
+
66
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
67
+ return self._inner.force_flush(timeout_millis)
68
+
69
+ def shutdown(self) -> None:
70
+ self._inner.shutdown()
71
+
72
+
73
+ class ReportingLogExporter(LogRecordExporter):
74
+ """Log-exporter twin of ReportingSpanExporter."""
75
+
76
+ def __init__(self, inner: LogRecordExporter, failures: ExportFailures) -> None:
77
+ self._inner = inner
78
+ self._failures = failures
79
+
80
+ def export(self, batch: Any) -> LogRecordExportResult:
81
+ try:
82
+ result = self._inner.export(batch)
83
+ except BaseException as exc:
84
+ self._failures.report("log export failed", exc)
85
+ return LogRecordExportResult.FAILURE
86
+ if result is LogRecordExportResult.FAILURE:
87
+ self._failures.report(
88
+ "log export failed",
89
+ RuntimeError("OTLP log export failed (check the API key and ingest URL)"),
90
+ )
91
+ else:
92
+ self._failures.clear()
93
+ return result
94
+
95
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
96
+ inner_flush = getattr(self._inner, "force_flush", None)
97
+ return inner_flush(timeout_millis) if inner_flush is not None else True
98
+
99
+ def shutdown(self) -> None:
100
+ self._inner.shutdown()
@@ -27,6 +27,7 @@ from opentelemetry.sdk.trace import ReadableSpan
27
27
  from opentelemetry.trace import INVALID_SPAN_CONTEXT, NonRecordingSpan, set_span_in_context
28
28
 
29
29
  from ._config import report_error
30
+ from ._export import ExportFailures
30
31
  from ._semconv import (
31
32
  ATTR_ERROR_TYPE,
32
33
  ATTR_TIME_TO_FIRST_CHUNK,
@@ -72,8 +73,7 @@ class _ChunkAggregate:
72
73
 
73
74
 
74
75
  class OutputChunkAggregation:
75
- def __init__(self, max_attribute_sets: int = _MAX_CHUNK_ATTRIBUTE_SETS) -> None:
76
- self._max_attribute_sets = max_attribute_sets
76
+ def __init__(self) -> None:
77
77
  self._values: dict[tuple[tuple[str, Any], ...], _ChunkAggregate] = {}
78
78
  self._lock = threading.Lock()
79
79
  self._collection_start = time.time_ns()
@@ -85,7 +85,7 @@ class OutputChunkAggregation:
85
85
  ) -> None:
86
86
  key = tuple(sorted(attributes.items()))
87
87
  with self._lock:
88
- if key not in self._values and len(self._values) >= self._max_attribute_sets - 1:
88
+ if key not in self._values and len(self._values) >= _MAX_CHUNK_ATTRIBUTE_SETS - 1:
89
89
  key = (("otel.metric.overflow", True),)
90
90
  count, total, minimum, maximum, buckets = aggregate
91
91
  existing = self._values.get(key)
@@ -167,16 +167,17 @@ def _has_data_points(metrics_data: MetricsData) -> bool:
167
167
 
168
168
  class GuardedOTLPMetricExporter(OTLPMetricExporter):
169
169
  """OTLP metric exporter that skips POSTs for collections with zero data points
170
- (mirrors the TS emitter's empty-datapoint guard) and surfaces failures to on_error."""
170
+ (mirrors the TS emitter's empty-datapoint guard) and reports failures through its
171
+ ExportFailures."""
171
172
 
172
173
  def __init__(
173
174
  self,
174
175
  *args: Any,
175
- on_error: Callable[[BaseException], None] | None = None,
176
+ failures: ExportFailures,
176
177
  **kwargs: Any,
177
178
  ) -> None:
178
179
  super().__init__(*args, **kwargs) # pyright: ignore[reportUnknownMemberType]
179
- self._td_on_error = on_error
180
+ self._td_failures = failures
180
181
  self.output_chunks: OutputChunkAggregation | None = None
181
182
 
182
183
  def export(
@@ -188,20 +189,22 @@ class GuardedOTLPMetricExporter(OTLPMetricExporter):
188
189
  if self.output_chunks is not None:
189
190
  metrics_data = _with_output_chunks(metrics_data, self.output_chunks)
190
191
  if not _has_data_points(metrics_data):
192
+ # No request reaches the ingest, so a series of failed exports continues.
191
193
  return MetricExportResult.SUCCESS
192
194
  try:
193
195
  result = super().export( # pyright: ignore[reportUnknownMemberType]
194
196
  metrics_data, timeout_millis=timeout_millis, **kwargs
195
197
  )
196
198
  except BaseException as exc:
197
- report_error(self._td_on_error, "metric export failed", exc)
199
+ self._td_failures.report("metric export failed", exc)
198
200
  return MetricExportResult.FAILURE
199
201
  if result is MetricExportResult.FAILURE:
200
- report_error(
201
- self._td_on_error,
202
+ self._td_failures.report(
202
203
  "metric export failed",
203
204
  RuntimeError("OTLP metric export failed (check the API key and ingest URL)"),
204
205
  )
206
+ else:
207
+ self._td_failures.clear()
205
208
  return result
206
209
 
207
210
 
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import re
6
+ from collections.abc import Callable, Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from typing import Any, cast
9
+
10
+ import rfc8785
11
+
12
+ from ._config import report_error
13
+
14
+ TRUNCATION_MARKER = "...[truncated]"
15
+ DEFAULT_MAX_ATTRIBUTE_LENGTH = 65536
16
+ _MAX_SAFE_INTEGER = 2**53 - 1
17
+ _MAX_ARRAY_INDEX = 2**32 - 2
18
+ _SURROGATES = re.compile("[\ud800-\udfff]")
19
+
20
+ AttributeValue = (
21
+ str | bool | int | float | Sequence[str] | Sequence[bool] | Sequence[int] | Sequence[float]
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class MaskContext:
27
+ key: str
28
+
29
+
30
+ Mask = Callable[[Any, MaskContext], Any]
31
+
32
+
33
+ def truncate(text: str, max_len: int, *, keep_end: bool = False) -> str:
34
+ """Cap ``text`` at ``max_len`` UTF-16 units, marking the cut.
35
+
36
+ ``keep_end`` keeps the tail and puts the marker first, for text whose most useful part
37
+ comes last.
38
+ """
39
+ # Count UTF-16 units like JavaScript, but back off at a split surrogate pair
40
+ # rather than emitting the lone surrogate that String.slice would retain.
41
+ if len(text) * 2 <= max_len:
42
+ return text
43
+ encoded = text.encode("utf-16-le")
44
+ if len(encoded) <= max_len * 2:
45
+ return text
46
+ if max_len < len(TRUNCATION_MARKER):
47
+ return TRUNCATION_MARKER[: max(max_len, 0)]
48
+ kept = (max_len - len(TRUNCATION_MARKER)) * 2
49
+ if keep_end:
50
+ tail = encoded[len(encoded) - kept :]
51
+ try:
52
+ return TRUNCATION_MARKER + tail.decode("utf-16-le")
53
+ except UnicodeDecodeError:
54
+ return TRUNCATION_MARKER + tail[2:].decode("utf-16-le")
55
+ head = encoded[:kept]
56
+ try:
57
+ return head.decode("utf-16-le") + TRUNCATION_MARKER
58
+ except UnicodeDecodeError:
59
+ return head[:-2].decode("utf-16-le") + TRUNCATION_MARKER
60
+
61
+
62
+ def _json_number(value: int | float) -> str:
63
+ if isinstance(value, int) and -_MAX_SAFE_INTEGER <= value <= _MAX_SAFE_INTEGER:
64
+ # int subclasses such as IntEnum on Python 3.10 may override __str__ with non-JSON text.
65
+ return int.__repr__(value)
66
+ try:
67
+ number = float(value)
68
+ except OverflowError:
69
+ return "null"
70
+ return rfc8785.dumps(number).decode("ascii") if math.isfinite(number) else "null"
71
+
72
+
73
+ def _json_string(value: str) -> str:
74
+ if _SURROGATES.search(value):
75
+ value = value.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "surrogatepass")
76
+ return _SURROGATES.sub(
77
+ lambda match: f"\\u{ord(match[0]):04x}", json.dumps(value, ensure_ascii=False)
78
+ )
79
+ return json.dumps(value, ensure_ascii=False)
80
+
81
+
82
+ def _json_key_order(key: str) -> tuple[int, int]:
83
+ if key.isascii() and key.isdigit() and len(key) <= 10:
84
+ index = int(key)
85
+ if index <= _MAX_ARRAY_INDEX and str(index) == key:
86
+ return 0, index
87
+ return 1, 0
88
+
89
+
90
+ def json_dumps(value: Any, *, default: Callable[[Any], Any] | None = repr) -> str:
91
+ """JSON.stringify spelling and key order for JSON values; default handles Python-only values."""
92
+ active: set[int] = set()
93
+ chunks: list[str] = []
94
+
95
+ def encode(item: Any) -> None:
96
+ if item is None:
97
+ chunks.append("null")
98
+ return
99
+ if isinstance(item, bool):
100
+ chunks.append("true" if item else "false")
101
+ return
102
+ if isinstance(item, str):
103
+ chunks.append(_json_string(item))
104
+ return
105
+ if isinstance(item, int | float):
106
+ chunks.append(_json_number(item))
107
+ return
108
+ identity = id(item)
109
+ if identity in active:
110
+ raise ValueError("Circular reference detected")
111
+ active.add(identity)
112
+ try:
113
+ if isinstance(item, dict):
114
+ properties: dict[str, Any] = {}
115
+ for key, child in cast("dict[Any, Any]", item).items():
116
+ if not isinstance(key, str):
117
+ if key is None:
118
+ key = "null"
119
+ elif isinstance(key, bool):
120
+ key = "true" if key else "false"
121
+ elif isinstance(key, float) and not math.isfinite(key):
122
+ key = json.dumps(key)
123
+ elif isinstance(key, int | float):
124
+ key = _json_number(key)
125
+ else:
126
+ raise TypeError("JSON object keys must be strings or scalars")
127
+ if _SURROGATES.search(key):
128
+ key = key.encode("utf-16-le", "surrogatepass").decode(
129
+ "utf-16-le", "surrogatepass"
130
+ )
131
+ properties[key] = child
132
+ chunks.append("{")
133
+ for index, key in enumerate(sorted(properties, key=_json_key_order)):
134
+ if index:
135
+ chunks.append(",")
136
+ chunks.extend((_json_string(key), ":"))
137
+ encode(properties[key])
138
+ chunks.append("}")
139
+ elif isinstance(item, list | tuple):
140
+ chunks.append("[")
141
+ for index, child in enumerate(cast("list[Any] | tuple[Any, ...]", item)):
142
+ if index:
143
+ chunks.append(",")
144
+ encode(child)
145
+ chunks.append("]")
146
+ elif default is not None:
147
+ encode(default(item))
148
+ else:
149
+ raise TypeError(f"Object of type {type(item).__name__} is not JSON serializable")
150
+ finally:
151
+ active.remove(identity)
152
+
153
+ encode(value)
154
+ return "".join(chunks)
155
+
156
+
157
+ def stringify(value: Any) -> str:
158
+ if isinstance(value, str):
159
+ return value
160
+ return json_dumps(value)
161
+
162
+
163
+ def serialize_content(
164
+ value: Any,
165
+ *,
166
+ key: str,
167
+ mask: Mask | None,
168
+ max_len: int,
169
+ on_error: Callable[[BaseException], None] | None = None,
170
+ keep_end: bool = False,
171
+ ) -> str | None:
172
+ """The single content funnel: mask -> JSON stringify -> truncate.
173
+
174
+ Returns None (content dropped, matching the TypeScript SDK) when the mask hook raises
175
+ or the value cannot be stringified. ``keep_end`` is passed to ``truncate``.
176
+ """
177
+ if mask is not None:
178
+ try:
179
+ value = mask(value, MaskContext(key=key))
180
+ except BaseException:
181
+ # Raised hook errors can retain unmasked values and the application exception chain.
182
+ message = "mask hook raised; content dropped"
183
+ report_error(on_error, message, RuntimeError(message))
184
+ return None
185
+ try:
186
+ return truncate(stringify(value), max_len, keep_end=keep_end)
187
+ except BaseException:
188
+ message = "failed to serialize attribute; content dropped"
189
+ report_error(on_error, message, ValueError(message))
190
+ return None
191
+
192
+
193
+ def coerce_attr_value(
194
+ value: Any,
195
+ *,
196
+ max_len: int = DEFAULT_MAX_ATTRIBUTE_LENGTH,
197
+ key: str | None = None,
198
+ on_error: Callable[[BaseException], None] | None = None,
199
+ ) -> AttributeValue | None:
200
+ """Pass scalars and homogeneous scalar sequences through; JSON-stringify everything else."""
201
+ try:
202
+ if isinstance(value, bool | int | float):
203
+ return value
204
+ if isinstance(value, str):
205
+ return truncate(value, max_len)
206
+ if isinstance(value, Sequence) and not isinstance(value, Mapping):
207
+ items: list[Any] = list(cast("Sequence[Any]", value))
208
+ if items and all(isinstance(item, str) for item in items):
209
+ return [truncate(item, max_len) for item in items]
210
+ if items and all(isinstance(item, bool) for item in items):
211
+ return items
212
+ if items and all(
213
+ isinstance(item, int | float) and not isinstance(item, bool) for item in items
214
+ ):
215
+ return items
216
+ return truncate(stringify(value), max_len)
217
+ except BaseException:
218
+ message = "failed to serialize attribute; content dropped"
219
+ report_error(on_error, message, ValueError(message))
220
+ return None
@@ -147,20 +147,32 @@ def _to_ns(value: TimeInput) -> int | None:
147
147
  return int(value)
148
148
 
149
149
 
150
- def _record_error_on_span(span: Span, error: BaseException) -> None:
150
+ def _record_error_on_span(
151
+ client: Client, span: Span, state: _SpanState, error: BaseException
152
+ ) -> None:
151
153
  error_type = type(error).__name__
152
- stacktrace = "".join(traceback.format_exception(type(error), error, error.__traceback__))
153
154
  span.set_attribute(ATTR_ERROR_TYPE, error_type)
154
- span.add_event(
155
- "exception",
156
- {
157
- "exception.type": error_type,
158
- "exception.message": str(error),
159
- "exception.stacktrace": stacktrace,
160
- "log.severity_number": SEVERITY["error"],
161
- },
162
- )
163
155
  span.set_status(Status(StatusCode.ERROR))
156
+ attributes: dict[str, otel_types.AttributeValue] = {
157
+ "exception.type": error_type,
158
+ "log.severity_number": SEVERITY["error"],
159
+ }
160
+ # Exceptions can echo either inputs or outputs; both must allow content capture.
161
+ if state.capture_input and state.capture_output:
162
+ # A Python traceback ends with the innermost frame and the exception line, so a
163
+ # truncated one keeps its end.
164
+ for key, value, keep_end in (
165
+ ("exception.message", str(error), False),
166
+ (
167
+ "exception.stacktrace",
168
+ "".join(traceback.format_exception(type(error), error, error.__traceback__)),
169
+ True,
170
+ ),
171
+ ):
172
+ serialized = client.serialize(value, key, keep_end=keep_end)
173
+ if serialized is not None:
174
+ attributes[key] = serialized
175
+ span.add_event("exception", attributes)
164
176
 
165
177
 
166
178
  def _metadata_attrs(
@@ -288,7 +300,7 @@ def _apply_fields(client: Client, span: Span, state: _SpanState, fields: dict[st
288
300
 
289
301
  error = fields.get("error")
290
302
  if error is not None:
291
- _record_error_on_span(span, error)
303
+ _record_error_on_span(client, span, state, error)
292
304
 
293
305
 
294
306
  def _collect_fields(
@@ -396,6 +408,19 @@ class SpanHandle:
396
408
  if self._client is not None:
397
409
  self._client.report("provider instrumentation failed", cause)
398
410
 
411
+ def serialize_content(self, value: Any, key: str) -> str | None:
412
+ """Prepare content written outside ``input`` and ``output`` the way those are prepared.
413
+
414
+ Runs the configured mask with ``key`` (the attribute the result is written to), JSON
415
+ serialization (strings pass through), then truncation to ``max_attribute_length``.
416
+ Returns None when the mask raises (reported to ``on_error``) or the value cannot be
417
+ serialized. Capture flags are not checked. Cores that predate this method lack it;
418
+ treat a missing method as masked.
419
+ """
420
+ if self._client is None:
421
+ return None
422
+ return self._client.serialize(value, key)
423
+
399
424
  def _recording(self) -> bool:
400
425
  return (
401
426
  self._client is not None
@@ -22,6 +22,7 @@ from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
22
22
  from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
23
23
 
24
24
  from ._config import SDK_VERSION, logger, report_error, resolve_config
25
+ from ._export import ExportFailures, ReportingSpanExporter
25
26
  from ._metrics import GuardedOTLPMetricExporter, MetricsRecorder, sdk_meter_provider
26
27
  from ._processor import ExportMode, StampingSpanProcessor
27
28
  from ._semconv import SCOPE_NAME
@@ -104,6 +105,9 @@ class TelemetrySpanProcessor(SpanProcessor):
104
105
  service_name=service_name,
105
106
  )
106
107
 
108
+ # The span and metric exporters use one failure state, as the exporters of a client do.
109
+ failures = ExportFailures(on_error)
110
+
107
111
  if span_exporter is None:
108
112
  span_exporter = create_telemetry_span_exporter(
109
113
  api_key=config.api_key,
@@ -111,6 +115,7 @@ class TelemetrySpanProcessor(SpanProcessor):
111
115
  )
112
116
  if isinstance(span_exporter, _NoOpSpanExporter):
113
117
  return
118
+ span_exporter = ReportingSpanExporter(span_exporter, failures)
114
119
 
115
120
  if metrics_recorder is None and metrics and config.api_key is not None:
116
121
  resource = Resource.create(
@@ -128,6 +133,7 @@ class TelemetrySpanProcessor(SpanProcessor):
128
133
  compression=Compression.Gzip,
129
134
  timeout=_EXPORTER_TIMEOUT_S,
130
135
  preferred_temporality={Histogram: AggregationTemporality.DELTA},
136
+ failures=failures,
131
137
  )
132
138
  reader = PeriodicExportingMetricReader(
133
139
  metric_exporter,
@@ -1,103 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- from collections.abc import Callable, Mapping, Sequence
5
- from dataclasses import dataclass
6
- from typing import Any, cast
7
-
8
- from ._config import report_error
9
-
10
- TRUNCATION_MARKER = "...[truncated]"
11
- DEFAULT_MAX_ATTRIBUTE_LENGTH = 65536
12
-
13
- AttributeValue = (
14
- str | bool | int | float | Sequence[str] | Sequence[bool] | Sequence[int] | Sequence[float]
15
- )
16
-
17
-
18
- @dataclass(frozen=True)
19
- class MaskContext:
20
- key: str
21
-
22
-
23
- Mask = Callable[[Any, MaskContext], Any]
24
-
25
-
26
- def truncate(text: str, max_len: int) -> str:
27
- # The cap counts UTF-16 code units (JavaScript's String.length) so both SDKs truncate
28
- # identical payloads at the same point; a slice landing mid-surrogate-pair backs off one unit.
29
- if len(text) * 2 <= max_len:
30
- return text
31
- encoded = text.encode("utf-16-le")
32
- if len(encoded) <= max_len * 2:
33
- return text
34
- if max_len < len(TRUNCATION_MARKER):
35
- return TRUNCATION_MARKER[: max(max_len, 0)]
36
- head = encoded[: (max_len - len(TRUNCATION_MARKER)) * 2]
37
- try:
38
- return head.decode("utf-16-le") + TRUNCATION_MARKER
39
- except UnicodeDecodeError:
40
- return head[:-2].decode("utf-16-le") + TRUNCATION_MARKER
41
-
42
-
43
- def stringify(value: Any) -> str:
44
- if isinstance(value, str):
45
- return value
46
- return json.dumps(value, default=repr, ensure_ascii=False)
47
-
48
-
49
- def serialize_content(
50
- value: Any,
51
- *,
52
- key: str,
53
- mask: Mask | None,
54
- max_len: int,
55
- on_error: Callable[[BaseException], None] | None = None,
56
- ) -> str | None:
57
- """The single content funnel: mask -> JSON stringify -> truncate.
58
-
59
- Returns None (content dropped, matching the TypeScript SDK) when the mask hook raises
60
- or the value cannot be stringified.
61
- """
62
- if mask is not None:
63
- try:
64
- value = mask(value, MaskContext(key=key))
65
- except BaseException as exc:
66
- report_error(on_error, f"mask hook raised for attribute '{key}'", exc)
67
- return None
68
- try:
69
- text = stringify(value)
70
- except BaseException as exc:
71
- report_error(on_error, f"failed to serialize attribute '{key}'", exc)
72
- return None
73
- return truncate(text, max_len)
74
-
75
-
76
- def coerce_attr_value(
77
- value: Any,
78
- *,
79
- max_len: int = DEFAULT_MAX_ATTRIBUTE_LENGTH,
80
- key: str | None = None,
81
- on_error: Callable[[BaseException], None] | None = None,
82
- ) -> AttributeValue | None:
83
- """Pass scalars and homogeneous scalar sequences through; JSON-stringify everything else."""
84
- try:
85
- if isinstance(value, bool | int | float):
86
- return value
87
- if isinstance(value, str):
88
- return truncate(value, max_len)
89
- if isinstance(value, Sequence) and not isinstance(value, Mapping):
90
- items: list[Any] = list(cast("Sequence[Any]", value))
91
- if items and all(isinstance(item, str) for item in items):
92
- return [truncate(item, max_len) for item in items]
93
- if items and all(isinstance(item, bool) for item in items):
94
- return items
95
- if items and all(
96
- isinstance(item, int | float) and not isinstance(item, bool) for item in items
97
- ):
98
- return items
99
- return truncate(stringify(value), max_len)
100
- except BaseException as exc:
101
- suffix = f" '{key}'" if key is not None else ""
102
- report_error(on_error, f"failed to serialize attribute{suffix}", exc)
103
- return None
File without changes