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.
@@ -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
+ ]
@@ -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)
@@ -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)