021-python-observability 0.3.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,209 @@
1
+ """JSON stdout logs. Only structured, approved fields cross the logging boundary."""
2
+
3
+ import logging
4
+ import re
5
+ import sys
6
+ from collections import deque
7
+ from pathlib import Path
8
+ from traceback import walk_tb
9
+ from types import TracebackType
10
+ from typing import cast
11
+ from uuid import UUID
12
+
13
+ import structlog
14
+ from opentelemetry import trace
15
+
16
+ _CORRELATION_FIELDS = (
17
+ "brain_id",
18
+ "job_id",
19
+ "file_id",
20
+ "file_version_id",
21
+ "entity_id",
22
+ "event_id",
23
+ "correlation_id",
24
+ )
25
+ _FIELDS = frozenset(
26
+ {
27
+ *_CORRELATION_FIELDS,
28
+ "event",
29
+ "level",
30
+ "timestamp",
31
+ "service_name",
32
+ "environment",
33
+ "service_version",
34
+ "request_id",
35
+ "trace_id",
36
+ "span_id",
37
+ "method",
38
+ "route",
39
+ "status_code",
40
+ "outcome",
41
+ "duration_ms",
42
+ "dependency",
43
+ "integration",
44
+ "operation",
45
+ "action",
46
+ "op",
47
+ "status",
48
+ "error_type",
49
+ "reason",
50
+ "signal",
51
+ "logger",
52
+ }
53
+ )
54
+
55
+
56
+ def _exception_details(exc_info):
57
+ if exc_info is True:
58
+ exc_info = sys.exc_info()
59
+ if isinstance(exc_info, BaseException):
60
+ exc_info = (type(exc_info), exc_info, exc_info.__traceback__)
61
+ if not isinstance(exc_info, tuple) or len(exc_info) != 3:
62
+ return {}
63
+ _, error, tb = exc_info
64
+ if not isinstance(error, BaseException):
65
+ return {}
66
+ chain, seen = [], set()
67
+ # Bound log size while retaining the frames nearest each failure. Never call
68
+ # format_exception/extract_tb: source lines, locals and error messages stay out.
69
+ while error is not None and id(error) not in seen and len(chain) < 8:
70
+ seen.add(id(error))
71
+ frames = deque(maxlen=32)
72
+ count = 0
73
+ if isinstance(tb, TracebackType):
74
+ for frame, line in walk_tb(tb):
75
+ frames.append(
76
+ {
77
+ "file": Path(frame.f_code.co_filename).name[:256],
78
+ "function": frame.f_code.co_name[:128],
79
+ "line": line,
80
+ }
81
+ )
82
+ count += 1
83
+ chain.append(
84
+ {
85
+ "type": type(error).__name__[:128],
86
+ "frames": list(frames),
87
+ "frames_truncated": count > len(frames),
88
+ }
89
+ )
90
+ cause = error.__cause__
91
+ error = (
92
+ cause
93
+ if cause is not None
94
+ else (None if error.__suppress_context__ else error.__context__)
95
+ )
96
+ tb = error.__traceback__ if error is not None else None
97
+ return {
98
+ "error_type": chain[0]["type"],
99
+ "exception": chain,
100
+ "exception_truncated": error is not None,
101
+ }
102
+
103
+
104
+ def _sanitize(_logger, _method, event):
105
+ context = trace.get_current_span().get_span_context()
106
+ if context.is_valid:
107
+ event["trace_id"] = f"{context.trace_id:032x}"
108
+ event["span_id"] = f"{context.span_id:016x}"
109
+ # Never serialize raw exceptions, arbitrary extras, bodies, headers or SQL. Standard
110
+ # logging messages may interpolate secrets; retain only their source category.
111
+ if not event.get("_from_structlog") and "_record" in event:
112
+ record = event["_record"]
113
+ event["event"] = getattr(record, "event", "library.log")
114
+ if record.name.startswith("opentelemetry.exporter") and record.levelno >= logging.WARNING:
115
+ event["event"] = "telemetry.export_failed"
116
+ event["reason"] = "export_failed"
117
+ event["signal"] = "metrics" if "metric" in record.name else "traces"
118
+ name = event.get("event", "log")
119
+ if not isinstance(name, str) or not re.fullmatch(r"[a-z][a-z0-9_.]{0,95}", name):
120
+ event["event"] = "log"
121
+ result = {key: value for key, value in event.items() if key in _FIELDS}
122
+ for key in _CORRELATION_FIELDS:
123
+ value = result.pop(key, None)
124
+ if isinstance(value, UUID):
125
+ result[key] = str(value)
126
+ elif isinstance(value, str) and len(value) <= 36:
127
+ try:
128
+ result[key] = str(UUID(value))
129
+ except ValueError:
130
+ pass
131
+ # Diagnostic fields are generated here, never accepted from arbitrary extras.
132
+ result.update(_exception_details(event.get("exc_info")))
133
+ result["message"] = " ".join(
134
+ f"{key}={result[key]}"
135
+ for key in (
136
+ "event",
137
+ "request_id",
138
+ "trace_id",
139
+ "span_id",
140
+ *_CORRELATION_FIELDS,
141
+ "method",
142
+ "route",
143
+ "status_code",
144
+ "duration_ms",
145
+ "operation",
146
+ "action",
147
+ "op",
148
+ "integration",
149
+ "error_type",
150
+ "reason",
151
+ )
152
+ if key in result
153
+ )
154
+ return result
155
+
156
+
157
+ def configure_logging(level: str = "INFO") -> None:
158
+ shared = [
159
+ structlog.contextvars.merge_contextvars,
160
+ structlog.stdlib.add_log_level,
161
+ structlog.stdlib.add_logger_name,
162
+ structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
163
+ ]
164
+ structlog.configure(
165
+ processors=[
166
+ structlog.stdlib.filter_by_level,
167
+ *shared,
168
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
169
+ ],
170
+ logger_factory=structlog.stdlib.LoggerFactory(),
171
+ wrapper_class=structlog.stdlib.BoundLogger,
172
+ cache_logger_on_first_use=False,
173
+ )
174
+ handler = logging.StreamHandler(sys.stdout)
175
+ handler.setFormatter(
176
+ structlog.stdlib.ProcessorFormatter(
177
+ processors=[_sanitize, structlog.processors.JSONRenderer(sort_keys=True)],
178
+ foreign_pre_chain=[*shared, structlog.stdlib.ExtraAdder(allow=_FIELDS)],
179
+ )
180
+ )
181
+ root = logging.getLogger()
182
+ root.handlers[:] = [handler]
183
+ root.setLevel(level)
184
+ # Uvicorn installs non-propagating handlers of its own. Route runtime errors
185
+ # through the same formatter so exception diagnostics use the safe frame schema.
186
+ for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
187
+ runtime_logger = logging.getLogger(name)
188
+ runtime_logger.handlers.clear()
189
+ runtime_logger.propagate = True
190
+ for name in (
191
+ "httpx",
192
+ "httpcore",
193
+ "httpx2",
194
+ "httpcore2",
195
+ "neo4j",
196
+ "sqlalchemy.engine",
197
+ "uvicorn.access",
198
+ ):
199
+ logging.getLogger(name).setLevel(logging.WARNING)
200
+
201
+
202
+ def get_logger(name: str) -> structlog.stdlib.BoundLogger:
203
+ """Use static event names and structured fields; never interpolate user data."""
204
+ return cast(structlog.stdlib.BoundLogger, structlog.get_logger(name))
205
+
206
+
207
+ def current_request_id() -> str | None:
208
+ value = structlog.contextvars.get_contextvars().get("request_id")
209
+ return value if isinstance(value, str) else None
File without changes
@@ -0,0 +1,360 @@
1
+ """Providers, instruments and bounded lifecycle for one application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import math
7
+ import re
8
+ import resource
9
+ import sys
10
+ import threading
11
+ import time
12
+ from collections.abc import Callable, Coroutine, Iterator, Mapping
13
+ from contextlib import contextmanager
14
+ from contextvars import ContextVar
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ if TYPE_CHECKING:
18
+ import httpx
19
+
20
+ from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
21
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
22
+ from opentelemetry.metrics import Observation
23
+ from opentelemetry.sdk.metrics import MeterProvider
24
+ from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
25
+ from opentelemetry.sdk.metrics.view import ExplicitBucketHistogramAggregation, View
26
+ from opentelemetry.sdk.resources import Resource
27
+ from opentelemetry.sdk.trace import TracerProvider
28
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
29
+ from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
30
+
31
+ from .config import Settings, identifier
32
+ from .logging import get_logger
33
+
34
+ # The package version, kept in step by release-please.
35
+ INSTRUMENTATION_VERSION = "0.3.0" # x-release-please-version
36
+
37
+ # Seconds, with finite boundaries for both ordinary API traffic and long ingestion.
38
+ DURATION_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300)
39
+ BackgroundTask = Callable[[Coroutine[Any, Any, None]], Any]
40
+ logger = get_logger(__name__)
41
+ current_observability: ContextVar[Observability | None] = ContextVar(
42
+ "observability_021", default=None
43
+ )
44
+
45
+
46
+ def error_kind(error: BaseException) -> str:
47
+ if isinstance(error, asyncio.CancelledError):
48
+ return "cancelled"
49
+ if isinstance(error, TimeoutError):
50
+ return "timeout"
51
+ if isinstance(error, OSError):
52
+ return "connection_error"
53
+ return "internal_error"
54
+
55
+
56
+ class Observability:
57
+ def __init__(
58
+ self,
59
+ settings: Settings,
60
+ *,
61
+ background_task: BackgroundTask | None = None,
62
+ prometheus: bool = False,
63
+ ):
64
+ self.settings = settings
65
+ self.background_task = background_task
66
+ self._closed = False
67
+ self._instrument_lock = threading.Lock()
68
+ self._instruments: dict[tuple[str, str], Any] = {}
69
+ self._cleanup: list[Callable[[], None]] = []
70
+ self._pools: list[tuple[str, Any]] = []
71
+ self._started = time.monotonic()
72
+ self.resource = Resource(
73
+ {
74
+ "service.name": settings.service_name,
75
+ "service.version": settings.service_version,
76
+ "service.instance.id": settings.instance_id,
77
+ "deployment.environment.name": settings.environment,
78
+ }
79
+ )
80
+ self.tracer_provider = TracerProvider(
81
+ resource=self.resource,
82
+ sampler=ParentBased(TraceIdRatioBased(settings.trace_sample_ratio)),
83
+ shutdown_on_exit=False,
84
+ )
85
+ # A nonempty explicit mapping prevents SDK fallback to ambient OTLP credentials.
86
+ # Content-Type is already required by OTLP/HTTP, so no extra header is introduced.
87
+ if settings.traces_endpoint:
88
+ self.tracer_provider.add_span_processor(
89
+ BatchSpanProcessor(
90
+ OTLPSpanExporter(
91
+ endpoint=settings.traces_endpoint,
92
+ headers={
93
+ "Content-Type": "application/x-protobuf",
94
+ **settings.traces_headers,
95
+ },
96
+ timeout=settings.export_timeout_seconds,
97
+ )
98
+ )
99
+ )
100
+ readers = []
101
+ self.prometheus_registry = None
102
+ if prometheus:
103
+ from opentelemetry.exporter.prometheus import PrometheusMetricReader
104
+ from prometheus_client import CollectorRegistry
105
+
106
+ self.prometheus_registry = CollectorRegistry()
107
+ readers.append(PrometheusMetricReader(registry=self.prometheus_registry))
108
+ if settings.metrics_endpoint:
109
+ readers.append(
110
+ PeriodicExportingMetricReader(
111
+ OTLPMetricExporter(
112
+ endpoint=settings.metrics_endpoint,
113
+ headers={
114
+ "Content-Type": "application/x-protobuf",
115
+ **settings.metrics_headers,
116
+ },
117
+ timeout=settings.export_timeout_seconds,
118
+ ),
119
+ export_interval_millis=settings.metric_interval_ms,
120
+ export_timeout_millis=settings.export_timeout_seconds * 1000,
121
+ )
122
+ )
123
+ self.meter_provider = MeterProvider(
124
+ resource=self.resource,
125
+ metric_readers=readers,
126
+ shutdown_on_exit=False,
127
+ views=[
128
+ View(
129
+ instrument_name="*.duration",
130
+ aggregation=ExplicitBucketHistogramAggregation(DURATION_BUCKETS),
131
+ )
132
+ ],
133
+ )
134
+ self.meter = self.meter_provider.get_meter("observability_021", INSTRUMENTATION_VERSION)
135
+ self.tracer = self.tracer_provider.get_tracer("observability_021", INSTRUMENTATION_VERSION)
136
+ self.requests = self.meter.create_counter("http.server.request.count", unit="{request}")
137
+ self.duration = self.meter.create_histogram("http.server.request.duration", unit="s")
138
+ self.active = self.meter.create_up_down_counter(
139
+ "http.server.active_requests", unit="{request}"
140
+ )
141
+ self.client_requests = self.meter.create_counter(
142
+ "http.client.request.count", unit="{request}"
143
+ )
144
+ self.client_duration = self.meter.create_histogram("http.client.request.duration", unit="s")
145
+ self.db_requests = self.meter.create_counter(
146
+ "db.client.operation.count", unit="{operation}"
147
+ )
148
+ self.db_duration = self.meter.create_histogram("db.client.operation.duration", unit="s")
149
+ self.operation_count = self.meter.create_counter("app.operation.count", unit="{operation}")
150
+ self.operation_duration = self.meter.create_histogram("app.operation.duration", unit="s")
151
+ self.meter.create_observable_counter(
152
+ "process.cpu.time", [lambda _: [Observation(time.process_time())]], unit="s"
153
+ )
154
+ self.meter.create_observable_gauge(
155
+ "process.uptime", [lambda _: [Observation(time.monotonic() - self._started)]], unit="s"
156
+ )
157
+ self.meter.create_observable_gauge(
158
+ "process.memory.peak_usage",
159
+ [
160
+ lambda _: [
161
+ Observation(
162
+ resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
163
+ * (1 if sys.platform == "darwin" else 1024)
164
+ )
165
+ ]
166
+ ],
167
+ unit="By",
168
+ )
169
+ self.meter.create_observable_gauge(
170
+ "process.thread.count",
171
+ [lambda _: [Observation(threading.active_count())]],
172
+ unit="{thread}",
173
+ )
174
+ # Keep providers local to this application. OTel's process-global setter is
175
+ # write-once and would silently bind subsequent create_app() calls elsewhere.
176
+
177
+ def increment(
178
+ self,
179
+ name: str,
180
+ value: int = 1,
181
+ *,
182
+ operation: str,
183
+ status: str,
184
+ attributes: Mapping[str, str] | None = None,
185
+ ) -> None:
186
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
187
+ raise ValueError("A counter increment cannot be negative")
188
+ self._instrument(name, "counter", "1").add(
189
+ value, self._attributes(operation, status, attributes)
190
+ )
191
+
192
+ def observe(
193
+ self,
194
+ name: str,
195
+ seconds: float,
196
+ *,
197
+ operation: str,
198
+ status: str,
199
+ attributes: Mapping[str, str] | None = None,
200
+ unit: str = "s",
201
+ ) -> None:
202
+ if not math.isfinite(seconds) or seconds < 0:
203
+ raise ValueError("Duration must be finite and nonnegative")
204
+ self._instrument(name, "histogram", unit).record(
205
+ seconds, self._attributes(operation, status, attributes)
206
+ )
207
+
208
+ def _instrument(self, name: str, kind: str, unit: str):
209
+ identifier(name)
210
+ if not name.startswith("app."):
211
+ raise ValueError("Domain metric names must start with app.")
212
+ if unit not in {"s", "1", "USD"}:
213
+ raise ValueError("Unsupported domain metric unit")
214
+ if kind == "histogram" and unit == "s" and not name.endswith(".duration"):
215
+ raise ValueError("Duration metric names must end with .duration")
216
+ with self._instrument_lock:
217
+ if (name, kind) not in self._instruments:
218
+ create = (
219
+ self.meter.create_counter if kind == "counter" else self.meter.create_histogram
220
+ )
221
+ self._instruments[name, kind] = create(name, unit=unit)
222
+ elif self._instruments[name, kind].unit != unit:
223
+ raise ValueError("A metric's unit cannot change")
224
+ return self._instruments[name, kind]
225
+
226
+ @staticmethod
227
+ def _attributes(operation: str, status: str, attributes=None) -> dict[str, str]:
228
+ identifier(operation)
229
+ identifier(status)
230
+ # Static domain dimensions used by the APIs. Never accept identifiers or payloads.
231
+ allowed = {
232
+ "event",
233
+ "code",
234
+ "scope",
235
+ "permission",
236
+ "action",
237
+ "reason",
238
+ "mode",
239
+ "channel",
240
+ "provider",
241
+ "dry_run",
242
+ "error_type",
243
+ }
244
+ safe = {
245
+ k: v if re.fullmatch(r"[A-Za-z0-9_.:/-]{1,96}", v) else "_OTHER"
246
+ for k, v in (attributes or {}).items()
247
+ if k in allowed
248
+ }
249
+ return {**safe, "operation": operation, "status": status}
250
+
251
+ def prometheus_latest(self) -> tuple[bytes, str]:
252
+ from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
253
+
254
+ if self.prometheus_registry is None:
255
+ raise RuntimeError("Enable the prometheus extra and reader at setup")
256
+ return generate_latest(self.prometheus_registry), CONTENT_TYPE_LATEST
257
+
258
+ def http_request(self, *, method: str, dependency: str, headers):
259
+ from .dependencies import observe_http_request
260
+
261
+ return observe_http_request(self, method=method, dependency=dependency, headers=headers)
262
+
263
+ @contextmanager
264
+ def operation(self, name: str) -> Iterator[None]:
265
+ """Works around sync work or around `await` inside an async function."""
266
+ identifier(name)
267
+ started, status = time.perf_counter(), "ok"
268
+ try:
269
+ yield
270
+ except BaseException as exc:
271
+ status = "cancelled" if isinstance(exc, asyncio.CancelledError) else "error"
272
+ raise
273
+ finally:
274
+ attributes = self._attributes(name, status)
275
+ self.operation_count.add(1, attributes)
276
+ self.operation_duration.record(time.perf_counter() - started, attributes)
277
+
278
+ def schedule_flush(self) -> None:
279
+ if self.background_task is None or self._closed:
280
+ return
281
+ coroutine = self.flush_async()
282
+ try:
283
+ self.background_task(coroutine)
284
+ except Exception:
285
+ coroutine.close()
286
+ logger.warning("telemetry.flush_failed", reason="schedule_failed")
287
+
288
+ async def flush_async(self) -> None:
289
+ # Export off the application event loop. Both signals share one wall-clock
290
+ # wait budget. Transport timeouts also bound the SDK's work in its threads.
291
+ timeout = self.settings.export_timeout_seconds
292
+ try:
293
+ await asyncio.wait_for(
294
+ asyncio.gather(
295
+ asyncio.to_thread(self._flush_signal, "traces", self.tracer_provider),
296
+ asyncio.to_thread(self._flush_signal, "metrics", self.meter_provider),
297
+ ),
298
+ timeout=timeout,
299
+ )
300
+ except TimeoutError:
301
+ logger.warning("telemetry.flush_failed", reason="timeout")
302
+
303
+ def _flush_signal(self, signal: str, provider) -> None:
304
+ try:
305
+ if not provider.force_flush(
306
+ timeout_millis=int(self.settings.export_timeout_seconds * 1000)
307
+ ):
308
+ logger.warning("telemetry.flush_failed", signal=signal, reason="timeout")
309
+ except Exception:
310
+ logger.warning("telemetry.flush_failed", signal=signal, reason="export_failed")
311
+
312
+ async def shutdown(self) -> None:
313
+ if self._closed:
314
+ return
315
+ self._closed = True
316
+ await self.flush_async()
317
+ for cleanup in self._cleanup:
318
+ cleanup()
319
+ # The SDK has synchronous shutdown methods; never block the ASGI event loop.
320
+ await asyncio.to_thread(
321
+ self.meter_provider.shutdown,
322
+ timeout_millis=int(self.settings.export_timeout_seconds * 1000),
323
+ )
324
+ await asyncio.to_thread(self.tracer_provider.shutdown)
325
+
326
+ def instrument_sqlalchemy(self, engine, *, dependency: str = "database") -> None:
327
+ from .dependencies import instrument_sqlalchemy
328
+
329
+ instrument_sqlalchemy(self, engine, dependency=dependency)
330
+
331
+ def httpx_transport(
332
+ self, *, dependency: str, transport: httpx.BaseTransport | None = None
333
+ ) -> httpx.BaseTransport:
334
+ from .dependencies import HTTPTransport
335
+
336
+ return HTTPTransport(self, dependency=dependency, transport=transport)
337
+
338
+ def async_httpx_transport(
339
+ self, *, dependency: str, transport: httpx.AsyncBaseTransport | None = None
340
+ ) -> httpx.AsyncBaseTransport:
341
+ from .dependencies import AsyncHTTPTransport
342
+
343
+ return AsyncHTTPTransport(self, dependency=dependency, transport=transport)
344
+
345
+ def add_health_routes(self, app, *, readiness=None) -> None:
346
+ from .asgi import add_health_routes
347
+
348
+ add_health_routes(app, self, readiness=readiness or {})
349
+
350
+
351
+ def vercel_background_task(settings: Settings) -> BackgroundTask | None:
352
+ if not settings.serverless or not (settings.traces_endpoint or settings.metrics_endpoint):
353
+ return None
354
+ try:
355
+ from vercel.functions import wait_until
356
+ except ImportError:
357
+ raise ValueError(
358
+ "Install 021-python-observability[vercel] for the Vercel runtime"
359
+ ) from None
360
+ return wait_until