telemetry-dev 0.1.0__py3-none-any.whl
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/__init__.py +55 -0
- telemetry_dev/_client.py +390 -0
- telemetry_dev/_config.py +81 -0
- telemetry_dev/_context.py +101 -0
- telemetry_dev/_logs.py +65 -0
- telemetry_dev/_metrics.py +120 -0
- telemetry_dev/_observe.py +142 -0
- telemetry_dev/_processor.py +77 -0
- telemetry_dev/_semconv.py +115 -0
- telemetry_dev/_serialize.py +103 -0
- telemetry_dev/_spans.py +676 -0
- telemetry_dev/otel.py +186 -0
- telemetry_dev/py.typed +0 -0
- telemetry_dev-0.1.0.dist-info/METADATA +235 -0
- telemetry_dev-0.1.0.dist-info/RECORD +16 -0
- telemetry_dev-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics.
|
|
2
|
+
|
|
3
|
+
Quickstart::
|
|
4
|
+
|
|
5
|
+
import telemetry_dev
|
|
6
|
+
|
|
7
|
+
telemetry_dev.init() # reads TELEMETRY_DEV_API_KEY
|
|
8
|
+
|
|
9
|
+
with telemetry_dev.start_span("chat gpt-4o", type="generation", model="gpt-4o",
|
|
10
|
+
provider="openai", input=messages) as span:
|
|
11
|
+
...
|
|
12
|
+
span.update(output=completion, usage={"input_tokens": 11, "output_tokens": 7})
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._client import Client, flush, get_client, init, shutdown
|
|
16
|
+
from ._config import SDK_VERSION as __version__
|
|
17
|
+
from ._config import LogLevelOption
|
|
18
|
+
from ._context import get_traceparent, propagate_attributes
|
|
19
|
+
from ._logs import log
|
|
20
|
+
from ._observe import observe
|
|
21
|
+
from ._semconv import LogLevel, SpanType
|
|
22
|
+
from ._serialize import AttributeValue, Mask, MaskContext
|
|
23
|
+
from ._spans import (
|
|
24
|
+
NOT_GIVEN,
|
|
25
|
+
SpanHandle,
|
|
26
|
+
Usage,
|
|
27
|
+
start_span,
|
|
28
|
+
update_current_span,
|
|
29
|
+
)
|
|
30
|
+
from .otel import TelemetrySpanProcessor
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"NOT_GIVEN",
|
|
34
|
+
"AttributeValue",
|
|
35
|
+
"Client",
|
|
36
|
+
"LogLevel",
|
|
37
|
+
"LogLevelOption",
|
|
38
|
+
"Mask",
|
|
39
|
+
"MaskContext",
|
|
40
|
+
"SpanHandle",
|
|
41
|
+
"SpanType",
|
|
42
|
+
"TelemetrySpanProcessor",
|
|
43
|
+
"Usage",
|
|
44
|
+
"__version__",
|
|
45
|
+
"flush",
|
|
46
|
+
"get_client",
|
|
47
|
+
"get_traceparent",
|
|
48
|
+
"init",
|
|
49
|
+
"log",
|
|
50
|
+
"observe",
|
|
51
|
+
"propagate_attributes",
|
|
52
|
+
"shutdown",
|
|
53
|
+
"start_span",
|
|
54
|
+
"update_current_span",
|
|
55
|
+
]
|
telemetry_dev/_client.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from opentelemetry import trace
|
|
9
|
+
from opentelemetry._logs import Logger as OtelLogger
|
|
10
|
+
from opentelemetry.exporter.otlp.proto.http import Compression
|
|
11
|
+
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
|
|
12
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
13
|
+
from opentelemetry.sdk._logs import LoggerProvider
|
|
14
|
+
from opentelemetry.sdk._logs.export import (
|
|
15
|
+
BatchLogRecordProcessor,
|
|
16
|
+
LogRecordExporter,
|
|
17
|
+
LogRecordExportResult,
|
|
18
|
+
SimpleLogRecordProcessor,
|
|
19
|
+
)
|
|
20
|
+
from opentelemetry.sdk.metrics import Histogram, MeterProvider
|
|
21
|
+
from opentelemetry.sdk.metrics.export import (
|
|
22
|
+
AggregationTemporality,
|
|
23
|
+
MetricReader,
|
|
24
|
+
PeriodicExportingMetricReader,
|
|
25
|
+
)
|
|
26
|
+
from opentelemetry.sdk.resources import Resource
|
|
27
|
+
from opentelemetry.sdk.trace import ReadableSpan, SpanLimits, TracerProvider
|
|
28
|
+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
29
|
+
from opentelemetry.trace import Tracer
|
|
30
|
+
|
|
31
|
+
from ._config import (
|
|
32
|
+
SDK_VERSION,
|
|
33
|
+
LogLevelOption,
|
|
34
|
+
ResolvedConfig,
|
|
35
|
+
configure_logger,
|
|
36
|
+
logger,
|
|
37
|
+
report_error,
|
|
38
|
+
resolve_config,
|
|
39
|
+
)
|
|
40
|
+
from ._metrics import GuardedOTLPMetricExporter, MetricsRecorder
|
|
41
|
+
from ._processor import ExportMode, StampingSpanProcessor
|
|
42
|
+
from ._semconv import SCOPE_NAME
|
|
43
|
+
from ._serialize import Mask, serialize_content
|
|
44
|
+
|
|
45
|
+
_lock = threading.RLock()
|
|
46
|
+
_client: Client | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _default_scope_filter(span: ReadableSpan) -> bool:
|
|
50
|
+
scope = span.instrumentation_scope
|
|
51
|
+
return scope is not None and scope.name == SCOPE_NAME
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _ReportingSpanExporter(SpanExporter):
|
|
55
|
+
"""Surfaces transport failures to on_error (TS parity: exporters route errors to onError,
|
|
56
|
+
the stock OTLP exporter only logs them)."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self, inner: SpanExporter, on_error: Callable[[BaseException], None] | None
|
|
60
|
+
) -> None:
|
|
61
|
+
self._inner = inner
|
|
62
|
+
self._on_error = on_error
|
|
63
|
+
|
|
64
|
+
def export(self, spans: Any) -> SpanExportResult:
|
|
65
|
+
try:
|
|
66
|
+
result = self._inner.export(spans)
|
|
67
|
+
except BaseException as exc:
|
|
68
|
+
report_error(self._on_error, "span export failed", exc)
|
|
69
|
+
return SpanExportResult.FAILURE
|
|
70
|
+
if result is SpanExportResult.FAILURE:
|
|
71
|
+
report_error(
|
|
72
|
+
self._on_error,
|
|
73
|
+
"span export failed",
|
|
74
|
+
RuntimeError("OTLP span export failed (check the API key and ingest URL)"),
|
|
75
|
+
)
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
79
|
+
return self._inner.force_flush(timeout_millis)
|
|
80
|
+
|
|
81
|
+
def shutdown(self) -> None:
|
|
82
|
+
self._inner.shutdown()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _ReportingLogExporter(LogRecordExporter):
|
|
86
|
+
"""Log-exporter twin of _ReportingSpanExporter."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self, inner: LogRecordExporter, on_error: Callable[[BaseException], None] | None
|
|
90
|
+
) -> None:
|
|
91
|
+
self._inner = inner
|
|
92
|
+
self._on_error = on_error
|
|
93
|
+
|
|
94
|
+
def export(self, batch: Any) -> LogRecordExportResult:
|
|
95
|
+
try:
|
|
96
|
+
result = self._inner.export(batch)
|
|
97
|
+
except BaseException as exc:
|
|
98
|
+
report_error(self._on_error, "log export failed", exc)
|
|
99
|
+
return LogRecordExportResult.FAILURE
|
|
100
|
+
if result is LogRecordExportResult.FAILURE:
|
|
101
|
+
report_error(
|
|
102
|
+
self._on_error,
|
|
103
|
+
"log export failed",
|
|
104
|
+
RuntimeError("OTLP log export failed (check the API key and ingest URL)"),
|
|
105
|
+
)
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
109
|
+
inner_flush = getattr(self._inner, "force_flush", None)
|
|
110
|
+
return inner_flush(timeout_millis) if inner_flush is not None else True
|
|
111
|
+
|
|
112
|
+
def shutdown(self) -> None:
|
|
113
|
+
self._inner.shutdown()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Client:
|
|
117
|
+
"""Holds the telemetry pipelines for one init() call. Obtain via telemetry_dev.init()."""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
*,
|
|
122
|
+
config: ResolvedConfig,
|
|
123
|
+
enabled: bool,
|
|
124
|
+
register_global: bool = False,
|
|
125
|
+
export_mode: ExportMode = "batched",
|
|
126
|
+
capture_input: bool = True,
|
|
127
|
+
capture_output: bool = True,
|
|
128
|
+
mask: Mask | None = None,
|
|
129
|
+
max_attribute_length: int = 65536,
|
|
130
|
+
span_filter: Callable[[ReadableSpan], bool] | None = None,
|
|
131
|
+
on_error: Callable[[BaseException], None] | None = None,
|
|
132
|
+
disable_atexit: bool = False,
|
|
133
|
+
timeout: float = 10.0,
|
|
134
|
+
span_exporter: SpanExporter | None = None,
|
|
135
|
+
log_exporter: LogRecordExporter | None = None,
|
|
136
|
+
metric_reader: MetricReader | None = None,
|
|
137
|
+
) -> None:
|
|
138
|
+
self.enabled = enabled
|
|
139
|
+
self.config = config
|
|
140
|
+
self.capture_input = capture_input
|
|
141
|
+
self.capture_output = capture_output
|
|
142
|
+
self.mask = mask
|
|
143
|
+
self.max_attribute_length = max_attribute_length
|
|
144
|
+
self.on_error = on_error
|
|
145
|
+
self._shutdown = False
|
|
146
|
+
self._atexit_registered = False
|
|
147
|
+
|
|
148
|
+
self.processor: StampingSpanProcessor | None = None
|
|
149
|
+
self.tracer: Tracer | None = None
|
|
150
|
+
self.otel_logger: OtelLogger | None = None
|
|
151
|
+
self._tracer_provider: TracerProvider | None = None
|
|
152
|
+
self._logger_provider: LoggerProvider | None = None
|
|
153
|
+
self._meter_provider: MeterProvider | None = None
|
|
154
|
+
|
|
155
|
+
if not enabled:
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
headers = {"Authorization": f"Bearer {config.api_key}"}
|
|
159
|
+
resource = Resource.create(
|
|
160
|
+
{
|
|
161
|
+
"service.name": config.service_name,
|
|
162
|
+
"deployment.environment.name": config.environment,
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# The funnel keeps capped content (marker included) within max_attribute_length, so
|
|
167
|
+
# the backstop can use the same cap for raw attributes set outside the funnel.
|
|
168
|
+
self._tracer_provider = TracerProvider(
|
|
169
|
+
resource=resource,
|
|
170
|
+
shutdown_on_exit=False,
|
|
171
|
+
span_limits=SpanLimits(
|
|
172
|
+
max_attribute_length=max_attribute_length,
|
|
173
|
+
max_span_attribute_length=max_attribute_length,
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Without an api key (enabled via a test seam), never construct real network
|
|
178
|
+
# exporters — they would POST to the ingest with a bogus Authorization header.
|
|
179
|
+
if metric_reader is None and config.api_key is not None:
|
|
180
|
+
metric_exporter = GuardedOTLPMetricExporter(
|
|
181
|
+
endpoint=f"{config.base_url}/v1/metrics",
|
|
182
|
+
headers=headers,
|
|
183
|
+
compression=Compression.Gzip,
|
|
184
|
+
timeout=timeout,
|
|
185
|
+
preferred_temporality={Histogram: AggregationTemporality.DELTA},
|
|
186
|
+
on_error=on_error,
|
|
187
|
+
)
|
|
188
|
+
metric_reader = PeriodicExportingMetricReader(
|
|
189
|
+
metric_exporter, export_interval_millis=60_000
|
|
190
|
+
)
|
|
191
|
+
metrics_recorder: MetricsRecorder | None = None
|
|
192
|
+
if metric_reader is not None:
|
|
193
|
+
self._meter_provider = MeterProvider(
|
|
194
|
+
metric_readers=[metric_reader], resource=resource, shutdown_on_exit=False
|
|
195
|
+
)
|
|
196
|
+
meter = self._meter_provider.get_meter(SCOPE_NAME, SDK_VERSION)
|
|
197
|
+
metrics_recorder = MetricsRecorder(meter, on_error=on_error)
|
|
198
|
+
|
|
199
|
+
if span_exporter is None:
|
|
200
|
+
span_exporter = _ReportingSpanExporter(
|
|
201
|
+
OTLPSpanExporter(
|
|
202
|
+
endpoint=f"{config.base_url}/v1/traces",
|
|
203
|
+
headers=headers,
|
|
204
|
+
compression=Compression.Gzip,
|
|
205
|
+
timeout=timeout,
|
|
206
|
+
),
|
|
207
|
+
on_error,
|
|
208
|
+
)
|
|
209
|
+
if span_filter is None and register_global:
|
|
210
|
+
span_filter = _default_scope_filter
|
|
211
|
+
self.processor = StampingSpanProcessor(
|
|
212
|
+
span_exporter,
|
|
213
|
+
export_mode=export_mode,
|
|
214
|
+
span_filter=span_filter,
|
|
215
|
+
metrics_recorder=metrics_recorder,
|
|
216
|
+
on_error=on_error,
|
|
217
|
+
)
|
|
218
|
+
self._tracer_provider.add_span_processor(self.processor)
|
|
219
|
+
self.tracer = self._tracer_provider.get_tracer(SCOPE_NAME, SDK_VERSION)
|
|
220
|
+
|
|
221
|
+
if log_exporter is None and config.api_key is not None:
|
|
222
|
+
log_exporter = _ReportingLogExporter(
|
|
223
|
+
OTLPLogExporter(
|
|
224
|
+
endpoint=f"{config.base_url}/v1/logs",
|
|
225
|
+
headers=headers,
|
|
226
|
+
compression=Compression.Gzip,
|
|
227
|
+
timeout=timeout,
|
|
228
|
+
),
|
|
229
|
+
on_error,
|
|
230
|
+
)
|
|
231
|
+
if log_exporter is not None:
|
|
232
|
+
self._logger_provider = LoggerProvider(resource=resource, shutdown_on_exit=False)
|
|
233
|
+
if export_mode == "immediate":
|
|
234
|
+
self._logger_provider.add_log_record_processor(
|
|
235
|
+
SimpleLogRecordProcessor(log_exporter)
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
self._logger_provider.add_log_record_processor(
|
|
239
|
+
BatchLogRecordProcessor(log_exporter)
|
|
240
|
+
)
|
|
241
|
+
self.otel_logger = self._logger_provider.get_logger(SCOPE_NAME, SDK_VERSION)
|
|
242
|
+
|
|
243
|
+
if register_global:
|
|
244
|
+
try:
|
|
245
|
+
trace.set_tracer_provider(self._tracer_provider)
|
|
246
|
+
except BaseException as exc:
|
|
247
|
+
self.report("failed to register global tracer provider", exc)
|
|
248
|
+
|
|
249
|
+
if not disable_atexit:
|
|
250
|
+
atexit.register(self.shutdown)
|
|
251
|
+
self._atexit_registered = True
|
|
252
|
+
|
|
253
|
+
def report(self, message: str, exc: BaseException) -> None:
|
|
254
|
+
report_error(self.on_error, message, exc)
|
|
255
|
+
|
|
256
|
+
def serialize(self, value: Any, key: str) -> str | None:
|
|
257
|
+
return serialize_content(
|
|
258
|
+
value,
|
|
259
|
+
key=key,
|
|
260
|
+
mask=self.mask,
|
|
261
|
+
max_len=self.max_attribute_length,
|
|
262
|
+
on_error=self.on_error,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def flush(self, timeout_s: float = 10.0) -> None:
|
|
266
|
+
if not self.enabled or self._shutdown:
|
|
267
|
+
return
|
|
268
|
+
timeout_millis = int(timeout_s * 1000)
|
|
269
|
+
try:
|
|
270
|
+
if self._tracer_provider is not None:
|
|
271
|
+
self._tracer_provider.force_flush(timeout_millis)
|
|
272
|
+
if self._logger_provider is not None:
|
|
273
|
+
self._logger_provider.force_flush(timeout_millis)
|
|
274
|
+
if self._meter_provider is not None:
|
|
275
|
+
self._meter_provider.force_flush(timeout_millis=timeout_millis)
|
|
276
|
+
except BaseException as exc:
|
|
277
|
+
self.report("flush failed", exc)
|
|
278
|
+
|
|
279
|
+
def shutdown(self, timeout_s: float = 10.0) -> None:
|
|
280
|
+
global _client
|
|
281
|
+
if self._shutdown:
|
|
282
|
+
return
|
|
283
|
+
self._shutdown = True
|
|
284
|
+
if self._atexit_registered:
|
|
285
|
+
try:
|
|
286
|
+
atexit.unregister(self.shutdown)
|
|
287
|
+
except BaseException:
|
|
288
|
+
pass
|
|
289
|
+
timeout_millis = int(timeout_s * 1000)
|
|
290
|
+
try:
|
|
291
|
+
if self._tracer_provider is not None:
|
|
292
|
+
self._tracer_provider.shutdown()
|
|
293
|
+
if self._logger_provider is not None:
|
|
294
|
+
self._logger_provider.shutdown()
|
|
295
|
+
if self._meter_provider is not None:
|
|
296
|
+
self._meter_provider.shutdown(timeout_millis=timeout_millis)
|
|
297
|
+
except BaseException as exc:
|
|
298
|
+
self.report("shutdown failed", exc)
|
|
299
|
+
with _lock:
|
|
300
|
+
if _client is self:
|
|
301
|
+
_client = None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def init(
|
|
305
|
+
*,
|
|
306
|
+
api_key: str | None = None,
|
|
307
|
+
base_url: str | None = None,
|
|
308
|
+
environment: str | None = None,
|
|
309
|
+
service_name: str | None = None,
|
|
310
|
+
enabled: bool = True,
|
|
311
|
+
register_global: bool = False,
|
|
312
|
+
export_mode: ExportMode = "batched",
|
|
313
|
+
log_level: LogLevelOption = "warn",
|
|
314
|
+
capture_input: bool = True,
|
|
315
|
+
capture_output: bool = True,
|
|
316
|
+
mask: Mask | None = None,
|
|
317
|
+
max_attribute_length: int = 65536,
|
|
318
|
+
span_filter: Callable[[ReadableSpan], bool] | None = None,
|
|
319
|
+
on_error: Callable[[BaseException], None] | None = None,
|
|
320
|
+
disable_atexit: bool = False,
|
|
321
|
+
timeout: float = 10.0,
|
|
322
|
+
span_exporter: SpanExporter | None = None,
|
|
323
|
+
log_exporter: LogRecordExporter | None = None,
|
|
324
|
+
metric_reader: MetricReader | None = None,
|
|
325
|
+
) -> Client:
|
|
326
|
+
"""Initialize the telemetry.dev SDK. Fail-open: a missing API key yields a silent no-op
|
|
327
|
+
client; internal errors are routed to on_error and the 'telemetry_dev' logger, never raised."""
|
|
328
|
+
global _client
|
|
329
|
+
configure_logger(log_level)
|
|
330
|
+
config = resolve_config(
|
|
331
|
+
api_key=api_key,
|
|
332
|
+
base_url=base_url,
|
|
333
|
+
environment=environment,
|
|
334
|
+
service_name=service_name,
|
|
335
|
+
)
|
|
336
|
+
has_seam = span_exporter is not None or log_exporter is not None or metric_reader is not None
|
|
337
|
+
effective_enabled = enabled and (has_seam or config.api_key is not None)
|
|
338
|
+
if enabled and not effective_enabled:
|
|
339
|
+
logger.debug("telemetry-dev disabled: no API key")
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
client = Client(
|
|
343
|
+
config=config,
|
|
344
|
+
enabled=effective_enabled,
|
|
345
|
+
register_global=register_global,
|
|
346
|
+
export_mode=export_mode,
|
|
347
|
+
capture_input=capture_input,
|
|
348
|
+
capture_output=capture_output,
|
|
349
|
+
mask=mask,
|
|
350
|
+
max_attribute_length=max_attribute_length,
|
|
351
|
+
span_filter=span_filter,
|
|
352
|
+
on_error=on_error,
|
|
353
|
+
disable_atexit=disable_atexit,
|
|
354
|
+
timeout=timeout,
|
|
355
|
+
span_exporter=span_exporter,
|
|
356
|
+
log_exporter=log_exporter,
|
|
357
|
+
metric_reader=metric_reader,
|
|
358
|
+
)
|
|
359
|
+
except BaseException as exc:
|
|
360
|
+
report_error(on_error, "init failed; telemetry disabled", exc)
|
|
361
|
+
client = Client(config=config, enabled=False)
|
|
362
|
+
|
|
363
|
+
with _lock:
|
|
364
|
+
previous = _client
|
|
365
|
+
_client = client
|
|
366
|
+
if previous is not None:
|
|
367
|
+
logger.warning("telemetry-dev: init() called again; replacing the previous client")
|
|
368
|
+
try:
|
|
369
|
+
previous.shutdown()
|
|
370
|
+
except BaseException as exc:
|
|
371
|
+
report_error(on_error, "failed to shut down the previous client", exc)
|
|
372
|
+
return client
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def get_client() -> Client | None:
|
|
376
|
+
return _client
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def flush(timeout_s: float = 10.0) -> None:
|
|
380
|
+
"""Force-flush pending traces, logs, and metrics (serverless-friendly)."""
|
|
381
|
+
client = _client
|
|
382
|
+
if client is not None:
|
|
383
|
+
client.flush(timeout_s)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def shutdown(timeout_s: float = 10.0) -> None:
|
|
387
|
+
"""Flush and tear down the SDK; resets the singleton to uninitialized."""
|
|
388
|
+
client = _client
|
|
389
|
+
if client is not None:
|
|
390
|
+
client.shutdown(timeout_s)
|
telemetry_dev/_config.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_URL = "https://ingest.telemetry.dev"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _resolve_version() -> str:
|
|
14
|
+
try:
|
|
15
|
+
from importlib.metadata import version
|
|
16
|
+
|
|
17
|
+
return version("telemetry-dev")
|
|
18
|
+
except Exception: # pragma: no cover - dev tree without installed dist
|
|
19
|
+
return "0.0.0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
SDK_VERSION = _resolve_version()
|
|
23
|
+
|
|
24
|
+
LogLevelOption = Literal["debug", "info", "warn", "error", "silent"]
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("telemetry_dev")
|
|
27
|
+
|
|
28
|
+
_LOG_LEVELS: dict[str, int] = {
|
|
29
|
+
"debug": logging.DEBUG,
|
|
30
|
+
"info": logging.INFO,
|
|
31
|
+
"warn": logging.WARNING,
|
|
32
|
+
"error": logging.ERROR,
|
|
33
|
+
"silent": logging.CRITICAL + 1,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ResolvedConfig:
|
|
39
|
+
api_key: str | None
|
|
40
|
+
base_url: str
|
|
41
|
+
environment: str
|
|
42
|
+
service_name: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_config(
|
|
46
|
+
*,
|
|
47
|
+
api_key: str | None = None,
|
|
48
|
+
base_url: str | None = None,
|
|
49
|
+
environment: str | None = None,
|
|
50
|
+
service_name: str | None = None,
|
|
51
|
+
) -> ResolvedConfig:
|
|
52
|
+
url = base_url or os.environ.get("TELEMETRY_DEV_BASE_URL") or DEFAULT_BASE_URL
|
|
53
|
+
return ResolvedConfig(
|
|
54
|
+
api_key=api_key or os.environ.get("TELEMETRY_DEV_API_KEY") or None,
|
|
55
|
+
base_url=re.sub(r"/+$", "", url),
|
|
56
|
+
environment=environment or os.environ.get("TELEMETRY_DEV_ENVIRONMENT") or "production",
|
|
57
|
+
service_name=service_name or os.environ.get("OTEL_SERVICE_NAME") or "unknown_service",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def configure_logger(log_level: LogLevelOption) -> None:
|
|
62
|
+
level = _LOG_LEVELS.get(log_level, logging.WARNING)
|
|
63
|
+
logger.setLevel(level)
|
|
64
|
+
if log_level != "silent" and not logger.handlers:
|
|
65
|
+
handler = logging.StreamHandler()
|
|
66
|
+
handler.setFormatter(logging.Formatter("%(name)s %(levelname)s %(message)s"))
|
|
67
|
+
logger.addHandler(handler)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def report_error(
|
|
71
|
+
on_error: Callable[[BaseException], None] | None,
|
|
72
|
+
message: str,
|
|
73
|
+
exc: BaseException,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Route an internal SDK error to the on_error hook + leveled logger; never raises."""
|
|
76
|
+
if on_error is not None:
|
|
77
|
+
try:
|
|
78
|
+
on_error(exc)
|
|
79
|
+
except BaseException:
|
|
80
|
+
logger.debug("telemetry-dev on_error hook raised", exc_info=True)
|
|
81
|
+
logger.error("telemetry-dev: %s", message, exc_info=exc)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Generator, Mapping
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from opentelemetry import context as otel_context
|
|
8
|
+
from opentelemetry import trace
|
|
9
|
+
from opentelemetry.context import Context
|
|
10
|
+
from opentelemetry.trace import NonRecordingSpan, SpanContext
|
|
11
|
+
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
|
12
|
+
|
|
13
|
+
from ._config import logger
|
|
14
|
+
from ._semconv import (
|
|
15
|
+
ATTR_SESSION_ID,
|
|
16
|
+
ATTR_USER_ID,
|
|
17
|
+
METADATA_PREFIX,
|
|
18
|
+
RESERVED_METADATA_KEYS,
|
|
19
|
+
)
|
|
20
|
+
from ._serialize import AttributeValue, coerce_attr_value
|
|
21
|
+
|
|
22
|
+
_PROPAGATED_KEY = otel_context.create_key("telemetry_dev.propagated")
|
|
23
|
+
|
|
24
|
+
_PROPAGATOR = TraceContextTextMapPropagator()
|
|
25
|
+
|
|
26
|
+
ParentInput = str | Context | SpanContext | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def propagated_attributes(context: Context | None = None) -> dict[str, AttributeValue]:
|
|
30
|
+
value = otel_context.get_value(_PROPAGATED_KEY, context=context)
|
|
31
|
+
if isinstance(value, Mapping):
|
|
32
|
+
return dict(cast("Mapping[str, AttributeValue]", value))
|
|
33
|
+
return {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@contextmanager
|
|
37
|
+
def propagate_attributes(
|
|
38
|
+
*,
|
|
39
|
+
user_id: str | None = None,
|
|
40
|
+
session_id: str | None = None,
|
|
41
|
+
metadata: Mapping[str, Any] | None = None,
|
|
42
|
+
) -> Generator[None]:
|
|
43
|
+
"""Stamp user.id / gen_ai.conversation.id / td.metadata.* on every span and log record
|
|
44
|
+
started inside this context. Nestable; inner values win on key conflicts."""
|
|
45
|
+
attrs = propagated_attributes()
|
|
46
|
+
if user_id is not None:
|
|
47
|
+
attrs[ATTR_USER_ID] = user_id
|
|
48
|
+
if session_id is not None:
|
|
49
|
+
attrs[ATTR_SESSION_ID] = session_id
|
|
50
|
+
if metadata is not None:
|
|
51
|
+
for key, value in metadata.items():
|
|
52
|
+
if key in RESERVED_METADATA_KEYS:
|
|
53
|
+
logger.debug(
|
|
54
|
+
"telemetry-dev: dropping reserved metadata key %r "
|
|
55
|
+
"(use user_id/session_id instead)",
|
|
56
|
+
key,
|
|
57
|
+
)
|
|
58
|
+
continue
|
|
59
|
+
attr = coerce_attr_value(value, key=f"{METADATA_PREFIX}{key}")
|
|
60
|
+
if attr is not None:
|
|
61
|
+
attrs[f"{METADATA_PREFIX}{key}"] = attr
|
|
62
|
+
token = otel_context.attach(otel_context.set_value(_PROPAGATED_KEY, attrs))
|
|
63
|
+
try:
|
|
64
|
+
yield
|
|
65
|
+
finally:
|
|
66
|
+
otel_context.detach(token)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_traceparent() -> str | None:
|
|
70
|
+
carrier: dict[str, str] = {}
|
|
71
|
+
_PROPAGATOR.inject(carrier)
|
|
72
|
+
return carrier.get("traceparent")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def traceparent_of(span_context: SpanContext) -> str | None:
|
|
76
|
+
if not span_context.is_valid:
|
|
77
|
+
return None
|
|
78
|
+
return (
|
|
79
|
+
f"00-{span_context.trace_id:032x}-{span_context.span_id:016x}"
|
|
80
|
+
f"-{span_context.trace_flags:02x}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def context_from_parent(parent: ParentInput) -> Context | None:
|
|
85
|
+
"""Resolve the `parent=` option into an OTel Context (None = current context)."""
|
|
86
|
+
if parent is None:
|
|
87
|
+
return None
|
|
88
|
+
if isinstance(parent, str):
|
|
89
|
+
# Anchor extraction on the current context: an invalid traceparent then falls back to
|
|
90
|
+
# the active span instead of silently starting a new root trace.
|
|
91
|
+
ctx = _PROPAGATOR.extract({"traceparent": parent}, context=otel_context.get_current())
|
|
92
|
+
elif isinstance(parent, SpanContext):
|
|
93
|
+
ctx = trace.set_span_in_context(NonRecordingSpan(parent))
|
|
94
|
+
else:
|
|
95
|
+
ctx = parent
|
|
96
|
+
# Preserve ambient propagated attributes when the parent is overridden explicitly.
|
|
97
|
+
if otel_context.get_value(_PROPAGATED_KEY, context=ctx) is None:
|
|
98
|
+
ambient = propagated_attributes()
|
|
99
|
+
if ambient:
|
|
100
|
+
ctx = otel_context.set_value(_PROPAGATED_KEY, ambient, context=ctx)
|
|
101
|
+
return ctx
|
telemetry_dev/_logs.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
|
|
5
|
+
from opentelemetry import context as otel_context
|
|
6
|
+
from opentelemetry._logs import SeverityNumber
|
|
7
|
+
|
|
8
|
+
from ._client import get_client
|
|
9
|
+
from ._config import logger
|
|
10
|
+
from ._context import propagated_attributes
|
|
11
|
+
from ._semconv import SEVERITY, LogLevel
|
|
12
|
+
from ._serialize import AttributeValue, coerce_attr_value
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def log(
|
|
16
|
+
message: str,
|
|
17
|
+
*,
|
|
18
|
+
level: LogLevel = "info",
|
|
19
|
+
event_name: str | None = None,
|
|
20
|
+
attributes: Mapping[str, AttributeValue] | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Emit an OTLP log record to /v1/logs, correlated with the current trace context.
|
|
23
|
+
Levels: debug | info | warn | error ("warning" is accepted as an alias of "warn")."""
|
|
24
|
+
client = get_client()
|
|
25
|
+
if client is None or not client.enabled or client.otel_logger is None:
|
|
26
|
+
return
|
|
27
|
+
try:
|
|
28
|
+
normalized = "warn" if level == "warning" else level
|
|
29
|
+
severity = SEVERITY.get(normalized)
|
|
30
|
+
if severity is None:
|
|
31
|
+
logger.debug("telemetry-dev: unknown log level %r; using 'info'", level)
|
|
32
|
+
normalized, severity = "info", SEVERITY["info"]
|
|
33
|
+
attrs: dict[str, AttributeValue] = {}
|
|
34
|
+
if attributes is not None:
|
|
35
|
+
for key, value in attributes.items():
|
|
36
|
+
attr = coerce_attr_value(
|
|
37
|
+
value,
|
|
38
|
+
max_len=client.max_attribute_length,
|
|
39
|
+
key=key,
|
|
40
|
+
on_error=client.on_error,
|
|
41
|
+
)
|
|
42
|
+
if attr is not None:
|
|
43
|
+
attrs[key] = attr
|
|
44
|
+
# Propagated correlation attrs win on key collisions, matching the span-processor
|
|
45
|
+
# stamping model and the TypeScript SDK.
|
|
46
|
+
for key, value in propagated_attributes().items():
|
|
47
|
+
attr = coerce_attr_value(
|
|
48
|
+
value,
|
|
49
|
+
max_len=client.max_attribute_length,
|
|
50
|
+
key=key,
|
|
51
|
+
on_error=client.on_error,
|
|
52
|
+
)
|
|
53
|
+
if attr is not None:
|
|
54
|
+
attrs[key] = attr
|
|
55
|
+
body = client.serialize(message, "log.message") or ""
|
|
56
|
+
client.otel_logger.emit(
|
|
57
|
+
context=otel_context.get_current(),
|
|
58
|
+
severity_number=SeverityNumber(severity),
|
|
59
|
+
severity_text=normalized.upper(),
|
|
60
|
+
body=body,
|
|
61
|
+
attributes=attrs or None,
|
|
62
|
+
event_name=event_name,
|
|
63
|
+
)
|
|
64
|
+
except BaseException as exc:
|
|
65
|
+
client.report("log() failed", exc)
|