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.
- 021_python_observability-0.3.0.dist-info/METADATA +348 -0
- 021_python_observability-0.3.0.dist-info/RECORD +11 -0
- 021_python_observability-0.3.0.dist-info/WHEEL +4 -0
- 021_python_observability-0.3.0.dist-info/licenses/LICENSE +202 -0
- observability_021/__init__.py +8 -0
- observability_021/asgi.py +266 -0
- observability_021/config.py +132 -0
- observability_021/dependencies.py +165 -0
- observability_021/logging.py +209 -0
- observability_021/py.typed +0 -0
- observability_021/runtime.py +360 -0
|
@@ -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
|
+
)
|