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,266 @@
1
+ """Outer ASGI observer: sees the response produced by FastAPI's error handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections.abc import Awaitable, Callable, Mapping
8
+ from contextlib import asynccontextmanager
9
+ from typing import Literal
10
+ from uuid import UUID, uuid4
11
+
12
+ import structlog
13
+ from fastapi import FastAPI
14
+ from opentelemetry.propagators.textmap import DefaultGetter
15
+ from opentelemetry.trace import SpanKind, StatusCode
16
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
17
+ from pydantic import BaseModel
18
+ from starlette.responses import JSONResponse
19
+
20
+ from .config import Settings, identifier
21
+ from .logging import configure_logging, get_logger
22
+ from .runtime import (
23
+ BackgroundTask,
24
+ Observability,
25
+ current_observability,
26
+ error_kind,
27
+ vercel_background_task,
28
+ )
29
+
30
+ _METHODS = frozenset(
31
+ {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "CONNECT", "TRACE"}
32
+ )
33
+ _PROPAGATOR = TraceContextTextMapPropagator()
34
+ logger = get_logger(__name__)
35
+
36
+
37
+ def request_method(method: str) -> str:
38
+ return method if method in _METHODS else "_OTHER"
39
+
40
+
41
+ class ObservabilityMiddleware:
42
+ def __init__(self, app, observability: Observability):
43
+ self.app, self.obs = app, observability
44
+
45
+ async def __call__(self, scope, receive, send):
46
+ if scope["type"] != "http":
47
+ return await self.app(scope, receive, send)
48
+ started = time.perf_counter()
49
+ method = request_method(scope.get("method", ""))
50
+ headers = {k.decode("latin1").lower(): v.decode("latin1") for k, v in scope["headers"]}
51
+ try:
52
+ request_id = str(UUID(headers.get("x-request-id", "")))
53
+ except (ValueError, AttributeError):
54
+ request_id = str(uuid4())
55
+ context = _PROPAGATOR.extract(headers, getter=DefaultGetter())
56
+ settings = self.obs.settings
57
+ tokens = structlog.contextvars.bind_contextvars(
58
+ request_id=request_id,
59
+ service_name=settings.service_name,
60
+ environment=settings.environment,
61
+ service_version=settings.service_version,
62
+ )
63
+ obs_token = current_observability.set(self.obs)
64
+ scope.setdefault("state", {})["request_id"] = request_id
65
+ status = None
66
+ completed, disconnected = False, False
67
+ failure = None
68
+ failure_class = None
69
+ self.obs.active.add(1, {"http.request.method": method})
70
+
71
+ async def observed_receive():
72
+ nonlocal disconnected
73
+ message = await receive()
74
+ if message["type"] == "http.disconnect":
75
+ disconnected = True
76
+ return message
77
+
78
+ async def observed_send(message):
79
+ nonlocal status, completed
80
+ if message["type"] == "http.response.start":
81
+ message = dict(message)
82
+ message["headers"] = [
83
+ (k, v) for k, v in message.get("headers", []) if k.lower() != b"x-request-id"
84
+ ] + [(b"x-request-id", request_id.encode("ascii"))]
85
+ await send(message)
86
+ if message["type"] == "http.response.start" and message["status"] >= 200:
87
+ status = message["status"]
88
+ if message["type"] == "http.response.body" and not message.get("more_body", False):
89
+ completed = status is not None
90
+
91
+ try:
92
+ with self.obs.tracer.start_as_current_span(
93
+ method,
94
+ context=context,
95
+ kind=SpanKind.SERVER,
96
+ record_exception=False,
97
+ set_status_on_exception=False,
98
+ ) as span:
99
+ try:
100
+ await self.app(scope, observed_receive, observed_send)
101
+ except BaseException as exc:
102
+ failure = error_kind(exc)
103
+ failure_class = type(exc).__name__
104
+ raise
105
+ finally:
106
+ duration = time.perf_counter() - started
107
+ # FastAPI 0.141 keeps the original route in scope["route"].
108
+ # Its effective route includes APIRouter prefixes (never raw paths).
109
+ effective = scope.get("fastapi", {}).get("effective_route_context")
110
+ route = (
111
+ getattr(effective, "path", None)
112
+ or getattr(scope.get("route"), "path", None)
113
+ or "__unresolved__"
114
+ )
115
+ outcome = (
116
+ "cancelled"
117
+ if failure == "cancelled"
118
+ else "completed"
119
+ if completed
120
+ else "disconnected"
121
+ if disconnected
122
+ else "error"
123
+ )
124
+ attrs = {
125
+ "http.request.method": method,
126
+ "http.route": route,
127
+ "response.outcome": outcome,
128
+ }
129
+ if status is not None:
130
+ attrs["http.response.status_code"] = status
131
+ if failure:
132
+ attrs["error.type"] = failure
133
+ span.update_name(f"{method} {route}")
134
+ span.set_attributes(attrs)
135
+ if failure or outcome != "completed" or (status is not None and status >= 500):
136
+ span.set_status(StatusCode.ERROR)
137
+ self.obs.requests.add(1, attrs)
138
+ self.obs.duration.record(duration, attrs)
139
+ self.obs.active.add(-1, {"http.request.method": method})
140
+ log = (
141
+ logger.warning
142
+ if failure or status is not None and status >= 500
143
+ else logger.info
144
+ )
145
+ log(
146
+ "request.completed",
147
+ method=method,
148
+ route=route,
149
+ status_code=status,
150
+ outcome=outcome,
151
+ duration_ms=round(duration * 1000, 3),
152
+ error_type=failure_class,
153
+ reason=failure,
154
+ exc_info=bool(failure),
155
+ )
156
+ finally:
157
+ # The span is closed BEFORE scheduling the Vercel continuation.
158
+ self.obs.schedule_flush()
159
+ structlog.contextvars.reset_contextvars(**tokens)
160
+ current_observability.reset(obs_token)
161
+
162
+
163
+ def setup_observability(
164
+ app: FastAPI,
165
+ *,
166
+ service_name: str | None = None,
167
+ settings: Settings | None = None,
168
+ background_task: BackgroundTask | None = None,
169
+ prometheus: bool = False,
170
+ ) -> Observability:
171
+ """Call once during application construction, before serving the first request."""
172
+ existing = getattr(app.state, "observability", None)
173
+ if existing is not None:
174
+ return existing
175
+ if app.middleware_stack is not None:
176
+ raise RuntimeError("Configure observability before the application starts")
177
+ settings = settings or Settings.from_env(service_name=service_name)
178
+ configure_logging(settings.log_level)
179
+ obs = Observability(
180
+ settings,
181
+ prometheus=prometheus,
182
+ background_task=(
183
+ background_task if background_task is not None else vercel_background_task(settings)
184
+ ),
185
+ )
186
+ original_build = app.build_middleware_stack
187
+ # FastAPI wraps user middleware inside ServerErrorMiddleware. Wrapping the built
188
+ # stack preserves the FastAPI object used by Vercel and observes real error responses.
189
+ app.build_middleware_stack = lambda: ObservabilityMiddleware(original_build(), obs)
190
+ original_lifespan = app.router.lifespan_context
191
+
192
+ @asynccontextmanager
193
+ async def lifespan(application):
194
+ # Servers accepting an already-built app may configure logging after setup.
195
+ configure_logging(settings.log_level)
196
+ token = current_observability.set(obs)
197
+ try:
198
+ async with original_lifespan(application) as state:
199
+ yield state
200
+ finally:
201
+ try:
202
+ await obs.shutdown()
203
+ finally:
204
+ current_observability.reset(token)
205
+
206
+ app.router.lifespan_context = lifespan
207
+ app.state.observability = obs
208
+ return obs
209
+
210
+
211
+ class HealthResponse(BaseModel):
212
+ status: Literal["ok"] = "ok"
213
+
214
+
215
+ class ReadyResponse(BaseModel):
216
+ status: Literal["ok", "unavailable"]
217
+ checks: dict[str, Literal["ok", "unavailable", "timeout"]]
218
+
219
+
220
+ def add_health_routes(
221
+ app: FastAPI,
222
+ obs: Observability,
223
+ *,
224
+ readiness: Mapping[str, Callable[[], Awaitable[object]]],
225
+ ) -> None:
226
+ if any(getattr(route, "path", None) in {"/health", "/ready"} for route in app.routes):
227
+ raise ValueError("Health routes already exist; migrate their semantics explicitly")
228
+ checks = {identifier(name): callback for name, callback in readiness.items()}
229
+
230
+ async def liveness():
231
+ return {"status": "ok"}
232
+
233
+ async def ready():
234
+ async def check(name, callback):
235
+ try:
236
+ result = await asyncio.wait_for(callback(), obs.settings.readiness_timeout_seconds)
237
+ return name, "unavailable" if result is False else "ok"
238
+ except TimeoutError:
239
+ return name, "timeout"
240
+ except Exception:
241
+ return name, "unavailable"
242
+
243
+ result = dict(await asyncio.gather(*(check(n, f) for n, f in checks.items())))
244
+ healthy = all(value == "ok" for value in result.values())
245
+ return JSONResponse(
246
+ {"status": "ok" if healthy else "unavailable", "checks": result},
247
+ status_code=200 if healthy else 503,
248
+ )
249
+
250
+ app.add_api_route(
251
+ "/health",
252
+ liveness,
253
+ methods=["GET"],
254
+ tags=["health"],
255
+ response_model=HealthResponse,
256
+ summary="Process liveness",
257
+ )
258
+ app.add_api_route(
259
+ "/ready",
260
+ ready,
261
+ methods=["GET"],
262
+ tags=["health"],
263
+ response_model=ReadyResponse,
264
+ responses={503: {"model": ReadyResponse}},
265
+ summary="Dependency readiness",
266
+ )
@@ -0,0 +1,132 @@
1
+ """OTLP/HTTP configuration. No remote export without an explicit endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ import re
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass, field
10
+ from urllib.parse import unquote, urlsplit
11
+ from uuid import uuid4
12
+
13
+
14
+ def identifier(value: str) -> str:
15
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}", value):
16
+ raise ValueError("Expected a static identifier of 1–96 characters")
17
+ return value
18
+
19
+
20
+ def _endpoint(value: str | None) -> str | None:
21
+ if not value:
22
+ return None
23
+ parsed = urlsplit(value)
24
+ if (
25
+ parsed.scheme not in {"http", "https"}
26
+ or not parsed.hostname
27
+ or parsed.username is not None
28
+ or parsed.password is not None
29
+ or parsed.query
30
+ or parsed.fragment
31
+ ):
32
+ raise ValueError("OTLP endpoint must be an HTTP(S) URL without credentials or query")
33
+ return value
34
+
35
+
36
+ def _headers(value: str) -> dict[str, str]:
37
+ headers: dict[str, str] = {}
38
+ for pair in value.split(","):
39
+ if not pair.strip():
40
+ continue
41
+ key, sep, val = pair.strip().partition("=")
42
+ if not sep or not re.fullmatch(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", key):
43
+ raise ValueError("Invalid OTLP header configuration")
44
+ val = unquote(val.strip())
45
+ if "\r" in val or "\n" in val:
46
+ raise ValueError("Invalid OTLP header configuration")
47
+ headers[key] = val
48
+ return headers
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Settings:
53
+ service_name: str
54
+ environment: str = "local"
55
+ service_version: str = "unknown"
56
+ instance_id: str = field(default_factory=lambda: str(uuid4()))
57
+ traces_endpoint: str | None = field(default=None, repr=False)
58
+ metrics_endpoint: str | None = field(default=None, repr=False)
59
+ traces_headers: Mapping[str, str] = field(default_factory=dict, repr=False)
60
+ metrics_headers: Mapping[str, str] = field(default_factory=dict, repr=False)
61
+ export_timeout_seconds: float = 2.0
62
+ metric_interval_ms: float = 60000
63
+ trace_sample_ratio: float = 1.0
64
+ log_level: str = "INFO"
65
+ serverless: bool = False
66
+ readiness_timeout_seconds: float = 2.0
67
+
68
+ def __post_init__(self) -> None:
69
+ identifier(self.service_name)
70
+ identifier(self.environment)
71
+ if not self.service_version or len(self.service_version) > 128:
72
+ raise ValueError("service_version must contain 1–128 characters")
73
+ _endpoint(self.traces_endpoint)
74
+ _endpoint(self.metrics_endpoint)
75
+ for value in (
76
+ self.export_timeout_seconds,
77
+ self.metric_interval_ms,
78
+ self.readiness_timeout_seconds,
79
+ ):
80
+ if not math.isfinite(value) or value <= 0:
81
+ raise ValueError("Timeouts and intervals must be finite and positive")
82
+ if not 0 <= self.trace_sample_ratio <= 1:
83
+ raise ValueError("trace_sample_ratio must be between zero and one")
84
+ if self.log_level not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
85
+ raise ValueError("Invalid log level")
86
+
87
+ @classmethod
88
+ def from_env(
89
+ cls, *, service_name: str | None = None, env: Mapping[str, str] | None = None
90
+ ) -> Settings:
91
+ env = os.environ if env is None else env
92
+ name = service_name or env.get("OTEL_SERVICE_NAME")
93
+ if not name:
94
+ raise ValueError("Set OTEL_SERVICE_NAME or pass service_name")
95
+ for signal in ("_TRACES", "_METRICS"):
96
+ protocol = env.get(
97
+ f"OTEL_EXPORTER_OTLP{signal}_PROTOCOL",
98
+ env.get("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf"),
99
+ )
100
+ if protocol != "http/protobuf":
101
+ raise ValueError("021-python-observability supports OTLP http/protobuf")
102
+ base = _endpoint(env.get("OTEL_EXPORTER_OTLP_ENDPOINT"))
103
+
104
+ def endpoint(signal: str) -> str | None:
105
+ return _endpoint(env.get(f"OTEL_EXPORTER_OTLP_{signal.upper()}_ENDPOINT")) or (
106
+ f"{base.rstrip('/')}/v1/{signal}" if base else None
107
+ )
108
+
109
+ return cls(
110
+ service_name=name,
111
+ environment=env.get("OTEL_DEPLOYMENT_ENVIRONMENT", env.get("VERCEL_ENV", "local")),
112
+ service_version=env.get(
113
+ "OTEL_SERVICE_VERSION", env.get("VERCEL_GIT_COMMIT_SHA", "unknown")
114
+ ),
115
+ traces_endpoint=endpoint("traces"),
116
+ metrics_endpoint=endpoint("metrics"),
117
+ traces_headers=_headers(
118
+ env.get(
119
+ "OTEL_EXPORTER_OTLP_TRACES_HEADERS", env.get("OTEL_EXPORTER_OTLP_HEADERS", "")
120
+ )
121
+ ),
122
+ metrics_headers=_headers(
123
+ env.get(
124
+ "OTEL_EXPORTER_OTLP_METRICS_HEADERS", env.get("OTEL_EXPORTER_OTLP_HEADERS", "")
125
+ )
126
+ ),
127
+ export_timeout_seconds=float(env.get("OTEL_EXPORTER_OTLP_TIMEOUT", "2")),
128
+ metric_interval_ms=float(env.get("OTEL_METRIC_EXPORT_INTERVAL", "60000")),
129
+ trace_sample_ratio=float(env.get("OBS_TRACE_SAMPLE_RATIO", "1")),
130
+ log_level=env.get("LOG_LEVEL", "INFO").upper(),
131
+ serverless=env.get("VERCEL") == "1",
132
+ )
@@ -0,0 +1,165 @@
1
+ """Explicit dependency instrumentation: stable names, no URLs or SQL text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from contextlib import contextmanager
7
+
8
+ import httpx
9
+ import structlog
10
+ from opentelemetry.trace import SpanKind, StatusCode
11
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
12
+ from sqlalchemy import event
13
+ from sqlalchemy.pool import QueuePool
14
+
15
+ from .config import identifier
16
+ from .runtime import error_kind
17
+
18
+
19
+ @contextmanager
20
+ def observe_http_request(obs, *, dependency, method, headers):
21
+ from .asgi import request_method
22
+
23
+ attributes = {
24
+ "dependency": identifier(dependency),
25
+ "http.request.method": request_method(method),
26
+ }
27
+ started = time.perf_counter()
28
+ with obs.tracer.start_as_current_span(
29
+ f"HTTP {dependency}",
30
+ kind=SpanKind.CLIENT,
31
+ attributes=attributes,
32
+ record_exception=False,
33
+ set_status_on_exception=False,
34
+ ) as span:
35
+ request_id = structlog.contextvars.get_contextvars().get("request_id")
36
+ # Plain dicts are case-sensitive; HTTP header names are not. Replace the
37
+ # caller's context instead of sending conflicting duplicate headers.
38
+ replaced = {"traceparent", "tracestate"}
39
+ if request_id:
40
+ replaced.add("x-request-id")
41
+ for key in list(headers):
42
+ if key.lower() in replaced:
43
+ del headers[key]
44
+ TraceContextTextMapPropagator().inject(headers)
45
+ if request_id:
46
+ headers["x-request-id"] = request_id
47
+ try:
48
+ yield attributes
49
+ except BaseException as exc:
50
+ attributes["error.type"] = (
51
+ "timeout" if isinstance(exc, httpx.TimeoutException) else error_kind(exc)
52
+ )
53
+ span.set_status(StatusCode.ERROR)
54
+ raise
55
+ finally:
56
+ if attributes.get("http.response.status_code", 0) >= 400:
57
+ span.set_status(StatusCode.ERROR)
58
+ span.set_attributes(attributes)
59
+ obs.client_requests.add(1, attributes)
60
+ obs.client_duration.record(time.perf_counter() - started, attributes)
61
+
62
+
63
+ class HTTPTransport(httpx.BaseTransport):
64
+ def __init__(self, obs, *, dependency: str, transport=None):
65
+ self.obs, self.dependency = obs, identifier(dependency)
66
+ self.transport = transport if transport is not None else httpx.HTTPTransport()
67
+
68
+ def handle_request(self, request):
69
+ with observe_http_request(
70
+ self.obs, dependency=self.dependency, method=request.method, headers=request.headers
71
+ ) as attrs:
72
+ response = self.transport.handle_request(request)
73
+ attrs["http.response.status_code"] = response.status_code
74
+ return response
75
+
76
+ def close(self):
77
+ self.transport.close()
78
+
79
+
80
+ class AsyncHTTPTransport(httpx.AsyncBaseTransport):
81
+ def __init__(self, obs, *, dependency: str, transport=None):
82
+ self.obs, self.dependency = obs, identifier(dependency)
83
+ self.transport = transport if transport is not None else httpx.AsyncHTTPTransport()
84
+
85
+ async def handle_async_request(self, request):
86
+ with observe_http_request(
87
+ self.obs, dependency=self.dependency, method=request.method, headers=request.headers
88
+ ) as attrs:
89
+ response = await self.transport.handle_async_request(request)
90
+ attrs["http.response.status_code"] = response.status_code
91
+ return response
92
+
93
+ async def aclose(self):
94
+ await self.transport.aclose()
95
+
96
+
97
+ def instrument_sqlalchemy(obs, engine, *, dependency: str):
98
+ """Supports sync engines and AsyncEngine via its public sync_engine property."""
99
+ identifier(dependency)
100
+ engine = getattr(engine, "sync_engine", engine)
101
+ existing = getattr(engine, "_observability_021", None)
102
+ if existing is obs:
103
+ return
104
+ if existing is not None:
105
+ raise ValueError("This engine is already observed by another application")
106
+ engine._observability_021 = obs
107
+
108
+ def before(_connection, _cursor, _statement, _parameters, context, _executemany):
109
+ context._observability_021 = (
110
+ time.perf_counter(),
111
+ obs.tracer.start_span(f"SQL {dependency}", kind=SpanKind.CLIENT),
112
+ )
113
+
114
+ def finish(context, error=None):
115
+ state = getattr(context, "_observability_021", None)
116
+ if state is None:
117
+ return
118
+ context._observability_021 = None
119
+ started, span = state
120
+ attrs = {"dependency": dependency, "status": "error" if error else "ok"}
121
+ if error:
122
+ attrs["error.type"] = error_kind(error)
123
+ span.set_status(StatusCode.ERROR)
124
+ span.set_attributes(attrs)
125
+ span.end()
126
+ obs.db_requests.add(1, attrs)
127
+ obs.db_duration.record(time.perf_counter() - started, attrs)
128
+
129
+ def after(_connection, _cursor, _statement, _parameters, context, _executemany):
130
+ finish(context)
131
+
132
+ def failed(context):
133
+ finish(context.execution_context, context.original_exception)
134
+
135
+ listeners = [
136
+ ("before_cursor_execute", before),
137
+ ("after_cursor_execute", after),
138
+ ("handle_error", failed),
139
+ ]
140
+ for name, fn in listeners:
141
+ event.listen(engine, name, fn)
142
+
143
+ def cleanup():
144
+ for name, fn in listeners:
145
+ event.remove(engine, name, fn)
146
+ del engine._observability_021
147
+
148
+ obs._cleanup.append(cleanup)
149
+
150
+ if isinstance(engine.pool, QueuePool):
151
+ from opentelemetry.metrics import Observation
152
+
153
+ obs._pools.append((dependency, engine.pool))
154
+ if len(obs._pools) == 1:
155
+
156
+ def pool_observations(_options):
157
+ return [
158
+ Observation(value, {"dependency": name, "state": state})
159
+ for name, pool in obs._pools
160
+ for state, value in (("used", pool.checkedout()), ("idle", pool.checkedin()))
161
+ ]
162
+
163
+ obs.meter.create_observable_gauge(
164
+ "db.client.connection.count", [pool_observations], unit="{connection}"
165
+ )