telemetry-dev 0.2.10__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.10 → telemetry_dev-0.2.11}/PKG-INFO +22 -8
- {telemetry_dev-0.2.10 → telemetry_dev-0.2.11}/README.md +20 -7
- {telemetry_dev-0.2.10 → telemetry_dev-0.2.11}/pyproject.toml +2 -1
- {telemetry_dev-0.2.10 → telemetry_dev-0.2.11}/pyproject.toml.orig +2 -1
- {telemetry_dev-0.2.10 → telemetry_dev-0.2.11}/src/telemetry_dev/_client.py +10 -70
- {telemetry_dev-0.2.10 → 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.10 → 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.10 → telemetry_dev-0.2.11}/src/telemetry_dev/_spans.py +24 -12
- {telemetry_dev-0.2.10 → telemetry_dev-0.2.11}/src/telemetry_dev/otel.py +6 -0
- telemetry_dev-0.2.10/src/telemetry_dev/_serialize.py +0 -102
- {telemetry_dev-0.2.10 → telemetry_dev-0.2.11}/LICENSE +0 -0
- {telemetry_dev-0.2.10 → telemetry_dev-0.2.11}/src/telemetry_dev/__init__.py +0 -0
- {telemetry_dev-0.2.10 → telemetry_dev-0.2.11}/src/telemetry_dev/_capture.py +0 -0
- {telemetry_dev-0.2.10 → telemetry_dev-0.2.11}/src/telemetry_dev/_context.py +0 -0
- {telemetry_dev-0.2.10 → telemetry_dev-0.2.11}/src/telemetry_dev/_logs.py +0 -0
- {telemetry_dev-0.2.10 → telemetry_dev-0.2.11}/src/telemetry_dev/_observe.py +0 -0
- {telemetry_dev-0.2.10 → telemetry_dev-0.2.11}/src/telemetry_dev/_processor.py +0 -0
- {telemetry_dev-0.2.10 → telemetry_dev-0.2.11}/src/telemetry_dev/_semconv.py +0 -0
- {telemetry_dev-0.2.10 → 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
|
|
@@ -151,6 +152,17 @@ the core SDK with provider integrations to enable the new metric.
|
|
|
151
152
|
`SpanContext`), `start_time`, and per-call `capture_input` / `capture_output` overrides;
|
|
152
153
|
`end()` additionally accepts `end_time`.
|
|
153
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
|
+
|
|
154
166
|
### init() options
|
|
155
167
|
|
|
156
168
|
| Option | Default | Purpose |
|
|
@@ -166,20 +178,22 @@ the core SDK with provider integrations to enable the new metric.
|
|
|
166
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. |
|
|
167
179
|
| `span_filter` | `None` | Export predicate `Callable[[ReadableSpan], bool]`. |
|
|
168
180
|
| `sampler` | OTel environment configuration | OTel `Sampler` that overrides environment settings. Session roots use its root policy. Real parents keep their sampling decisions. |
|
|
169
|
-
| `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. |
|
|
170
182
|
| `disable_atexit` | `False` | Skip the automatic atexit shutdown. |
|
|
171
183
|
| `timeout` | `10.0` | OTLP HTTP timeout in seconds. |
|
|
172
184
|
| `span_exporter`, `log_exporter`, `metric_reader` | `None` | Test seams / offline mode; any of them enables the client without an API key. |
|
|
173
185
|
|
|
174
186
|
`capture_input=False` and `capture_output=False` omit captured content. Provider integrations may
|
|
175
187
|
still record non-content metadata such as response IDs and models, finish reasons, usage, timing,
|
|
176
|
-
and
|
|
177
|
-
|
|
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.
|
|
178
191
|
|
|
179
|
-
`mask` receives input and output content, system instructions, log messages,
|
|
192
|
+
`mask` receives input and output content, system instructions, log messages, captured exception
|
|
193
|
+
messages and stack traces, and content that
|
|
180
194
|
integrations pass to `SpanHandle.serialize_content`. `MaskContext.key` names the attribute being
|
|
181
|
-
written. It does not receive span or propagated `metadata`, raw `attributes`, log attributes
|
|
182
|
-
|
|
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.
|
|
183
197
|
|
|
184
198
|
Session roots share trace IDs from the API key and session ID. The hash matches the TypeScript SDK.
|
|
185
199
|
The SDK uses the configured OTel sampling policy, not the synthetic parent's flags.
|
|
@@ -253,7 +267,7 @@ from telemetry_dev import TelemetrySpanProcessor
|
|
|
253
267
|
| `metrics` | `True` | Auto-record GenAI duration/token histograms for exported spans when an API key is available. |
|
|
254
268
|
| `service_name` | `OTEL_SERVICE_NAME` or `unknown_service` | `service.name` on the metrics resource. |
|
|
255
269
|
| `environment` | `TELEMETRY_DEV_ENVIRONMENT` or `production` | `deployment.environment.name` on the metrics resource. |
|
|
256
|
-
| `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. |
|
|
257
271
|
| `span_exporter` | `None` | Advanced/test seam replacing the telemetry.dev OTLP trace exporter. |
|
|
258
272
|
|
|
259
273
|
Without an API key and without `span_exporter`, `TelemetrySpanProcessor()` is an inert no-op:
|
|
@@ -127,6 +127,17 @@ the core SDK with provider integrations to enable the new metric.
|
|
|
127
127
|
`SpanContext`), `start_time`, and per-call `capture_input` / `capture_output` overrides;
|
|
128
128
|
`end()` additionally accepts `end_time`.
|
|
129
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
|
+
|
|
130
141
|
### init() options
|
|
131
142
|
|
|
132
143
|
| Option | Default | Purpose |
|
|
@@ -142,20 +153,22 @@ the core SDK with provider integrations to enable the new metric.
|
|
|
142
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. |
|
|
143
154
|
| `span_filter` | `None` | Export predicate `Callable[[ReadableSpan], bool]`. |
|
|
144
155
|
| `sampler` | OTel environment configuration | OTel `Sampler` that overrides environment settings. Session roots use its root policy. Real parents keep their sampling decisions. |
|
|
145
|
-
| `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. |
|
|
146
157
|
| `disable_atexit` | `False` | Skip the automatic atexit shutdown. |
|
|
147
158
|
| `timeout` | `10.0` | OTLP HTTP timeout in seconds. |
|
|
148
159
|
| `span_exporter`, `log_exporter`, `metric_reader` | `None` | Test seams / offline mode; any of them enables the client without an API key. |
|
|
149
160
|
|
|
150
161
|
`capture_input=False` and `capture_output=False` omit captured content. Provider integrations may
|
|
151
162
|
still record non-content metadata such as response IDs and models, finish reasons, usage, timing,
|
|
152
|
-
and
|
|
153
|
-
|
|
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.
|
|
154
166
|
|
|
155
|
-
`mask` receives input and output content, system instructions, log messages,
|
|
167
|
+
`mask` receives input and output content, system instructions, log messages, captured exception
|
|
168
|
+
messages and stack traces, and content that
|
|
156
169
|
integrations pass to `SpanHandle.serialize_content`. `MaskContext.key` names the attribute being
|
|
157
|
-
written. It does not receive span or propagated `metadata`, raw `attributes`, log attributes
|
|
158
|
-
|
|
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.
|
|
159
172
|
|
|
160
173
|
Session roots share trace IDs from the API key and session ID. The hash matches the TypeScript SDK.
|
|
161
174
|
The SDK uses the configured OTel sampling policy, not the synthetic parent's flags.
|
|
@@ -229,7 +242,7 @@ from telemetry_dev import TelemetrySpanProcessor
|
|
|
229
242
|
| `metrics` | `True` | Auto-record GenAI duration/token histograms for exported spans when an API key is available. |
|
|
230
243
|
| `service_name` | `OTEL_SERVICE_NAME` or `unknown_service` | `service.name` on the metrics resource. |
|
|
231
244
|
| `environment` | `TELEMETRY_DEV_ENVIRONMENT` or `production` | `deployment.environment.name` on the metrics resource. |
|
|
232
|
-
| `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. |
|
|
233
246
|
| `span_exporter` | `None` | Advanced/test seam replacing the telemetry.dev OTLP trace exporter. |
|
|
234
247
|
|
|
235
248
|
Without an API key and without `span_exporter`, `TelemetrySpanProcessor()` is an inert no-op:
|
|
@@ -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(
|
|
@@ -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,102 +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
|
-
return truncate(stringify(value), max_len)
|
|
70
|
-
except BaseException as exc:
|
|
71
|
-
report_error(on_error, f"failed to serialize attribute '{key}'", exc)
|
|
72
|
-
return None
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def coerce_attr_value(
|
|
76
|
-
value: Any,
|
|
77
|
-
*,
|
|
78
|
-
max_len: int = DEFAULT_MAX_ATTRIBUTE_LENGTH,
|
|
79
|
-
key: str | None = None,
|
|
80
|
-
on_error: Callable[[BaseException], None] | None = None,
|
|
81
|
-
) -> AttributeValue | None:
|
|
82
|
-
"""Pass scalars and homogeneous scalar sequences through; JSON-stringify everything else."""
|
|
83
|
-
try:
|
|
84
|
-
if isinstance(value, bool | int | float):
|
|
85
|
-
return value
|
|
86
|
-
if isinstance(value, str):
|
|
87
|
-
return truncate(value, max_len)
|
|
88
|
-
if isinstance(value, Sequence) and not isinstance(value, Mapping):
|
|
89
|
-
items: list[Any] = list(cast("Sequence[Any]", value))
|
|
90
|
-
if items and all(isinstance(item, str) for item in items):
|
|
91
|
-
return [truncate(item, max_len) for item in items]
|
|
92
|
-
if items and all(isinstance(item, bool) for item in items):
|
|
93
|
-
return items
|
|
94
|
-
if items and all(
|
|
95
|
-
isinstance(item, int | float) and not isinstance(item, bool) for item in items
|
|
96
|
-
):
|
|
97
|
-
return items
|
|
98
|
-
return truncate(stringify(value), max_len)
|
|
99
|
-
except BaseException as exc:
|
|
100
|
-
suffix = f" '{key}'" if key is not None else ""
|
|
101
|
-
report_error(on_error, f"failed to serialize attribute{suffix}", exc)
|
|
102
|
-
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
|