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.
- {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/PKG-INFO +95 -6
- {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/README.md +93 -5
- {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/pyproject.toml +2 -1
- {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/pyproject.toml.orig +2 -1
- {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_client.py +10 -70
- {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_config.py +6 -2
- telemetry_dev-0.2.11/src/telemetry_dev/_export.py +100 -0
- {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_metrics.py +12 -9
- telemetry_dev-0.2.11/src/telemetry_dev/_serialize.py +220 -0
- {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_spans.py +37 -12
- {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/otel.py +6 -0
- telemetry_dev-0.2.9/src/telemetry_dev/_serialize.py +0 -103
- {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/LICENSE +0 -0
- {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/__init__.py +0 -0
- {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_capture.py +0 -0
- {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_context.py +0 -0
- {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_logs.py +0 -0
- {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_observe.py +0 -0
- {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_processor.py +0 -0
- {telemetry_dev-0.2.9 → telemetry_dev-0.2.11}/src/telemetry_dev/_semconv.py +0 -0
- {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.
|
|
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
|
|
173
|
-
|
|
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
|
|
149
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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"
|
|
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
|
|
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) >=
|
|
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
|
|
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
|
-
|
|
176
|
+
failures: ExportFailures,
|
|
176
177
|
**kwargs: Any,
|
|
177
178
|
) -> None:
|
|
178
179
|
super().__init__(*args, **kwargs) # pyright: ignore[reportUnknownMemberType]
|
|
179
|
-
self.
|
|
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
|
-
|
|
199
|
+
self._td_failures.report("metric export failed", exc)
|
|
198
200
|
return MetricExportResult.FAILURE
|
|
199
201
|
if result is MetricExportResult.FAILURE:
|
|
200
|
-
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|