fastapi-redline 0.2.0a1__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,38 @@
1
+ """fastapi-redline: the FastAPI + SQLAlchemy adapter for Redline.
2
+
3
+ ::
4
+
5
+ from fastapi import FastAPI
6
+ from sqlalchemy.ext.asyncio import create_async_engine
7
+ from fastapi_redline import install
8
+
9
+ app = FastAPI()
10
+ engine = create_async_engine("sqlite+aiosqlite:///app.db")
11
+ install(app, engine=engine) # UI at /_redline/
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from ._httpx import instrument_httpx, uninstrument_httpx
17
+ from ._middleware import RedlineMiddleware
18
+ from ._security import RedlineHTTPError, csrf_guard, local_only, loopback_guard
19
+ from ._sqlalchemy import instrument_engine
20
+ from .integration import Redline, install
21
+ from .suggest import SQLAlchemyFixSuggester
22
+
23
+ __version__ = "0.2.0a1"
24
+
25
+ __all__ = [
26
+ "Redline",
27
+ "RedlineHTTPError",
28
+ "RedlineMiddleware",
29
+ "SQLAlchemyFixSuggester",
30
+ "__version__",
31
+ "csrf_guard",
32
+ "install",
33
+ "instrument_engine",
34
+ "instrument_httpx",
35
+ "local_only",
36
+ "loopback_guard",
37
+ "uninstrument_httpx",
38
+ ]
@@ -0,0 +1,5 @@
1
+ """Compatibility shim: the implementation lives in :mod:`redline_core.contrib.httpx`."""
2
+
3
+ from redline_core.contrib.httpx import instrument_httpx, uninstrument_httpx
4
+
5
+ __all__ = ["instrument_httpx", "uninstrument_httpx"]
@@ -0,0 +1,264 @@
1
+ """The pure-ASGI recording middleware.
2
+
3
+ Not ``BaseHTTPMiddleware``: that wraps the app in extra tasks, breaks contextvars
4
+ propagation for sync endpoints and buffers streaming responses. This one:
5
+
6
+ * *tees* the request body as the app reads it (the app sees the untouched ``receive``);
7
+ * wraps ``send`` to capture status, headers and a size-capped copy of the response body –
8
+ a stream is never buffered beyond ``max_body_bytes``;
9
+ * finishes the trace right after the last body chunk is sent, so background tasks do not
10
+ count towards latency;
11
+ * resolves the route template (``/orders/{id}``) from ``scope["route"]`` once routing ran;
12
+ * adds ``Server-Timing`` to the response start (``server_timing`` setting) and injects the
13
+ floating badge into small, single-message HTML pages (``badge`` setting): the start
14
+ message is held back only when it announces a 2xx ``text/html`` body that is not
15
+ compressed, allowed by the CSP and at most ``BADGE_MAX_BYTES`` long; a body that turns
16
+ out to be streamed (``more_body``) is passed through untouched.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from collections.abc import Callable
23
+ from typing import Any
24
+
25
+ from redline_core import Recorder, get_recorder, ui
26
+ from redline_core.contrib.asgi import track_request
27
+ from redline_core.sampling import profile_requested
28
+
29
+ __all__ = ["RedlineMiddleware"]
30
+
31
+ _JSON_PAIR = re.compile(r'("(?:[^"\\]|\\.)*")(\s*:\s*)("(?:[^"\\]|\\.)*"|[^,}\]\s]+)')
32
+
33
+
34
+ def _wants_profile(headers: list[tuple[bytes, bytes]], query: bytes) -> bool:
35
+ header = next((v for k, v in headers if k == b"x-redline-profile"), None)
36
+ return profile_requested(query, header)
37
+
38
+
39
+ def _header(headers: list[tuple[bytes, bytes]], name: bytes) -> str | None:
40
+ for key, value in headers:
41
+ if key.lower() == name:
42
+ return value.decode("latin-1")
43
+ return None
44
+
45
+
46
+ def _endpoint_name(endpoint: Any) -> str | None:
47
+ if endpoint is None:
48
+ return None
49
+ target = getattr(endpoint, "func", endpoint) # functools.partial
50
+ module = getattr(target, "__module__", None)
51
+ name = getattr(target, "__qualname__", None) or getattr(target, "__name__", None)
52
+ if name is None:
53
+ name = type(target).__qualname__
54
+ module = type(target).__module__
55
+ return f"{module}.{name}" if module else name
56
+
57
+
58
+ class _Capture:
59
+ """A byte buffer that keeps at most ``limit + 1`` bytes (the +1 lets core flag truncation)."""
60
+
61
+ __slots__ = ("buf", "limit", "total")
62
+
63
+ def __init__(self, limit: int) -> None:
64
+ self.buf = bytearray()
65
+ self.limit = limit
66
+ self.total = 0
67
+
68
+ def add(self, chunk: bytes) -> None:
69
+ self.total += len(chunk)
70
+ room = self.limit + 1 - len(self.buf)
71
+ if room > 0 and chunk:
72
+ self.buf += chunk[:room]
73
+
74
+ def value(self, recorder: Recorder, content_type: str | None) -> bytes:
75
+ data = bytes(self.buf)
76
+ if len(data) > self.limit and content_type and "json" in content_type:
77
+ # A cut JSON document cannot be parsed by core's key-based masking; mask the
78
+ # visible "key": value pairs so a secret in the first bytes never leaks.
79
+ redactor = recorder.redactor
80
+ text = data.decode("utf-8", errors="ignore")
81
+ text = _JSON_PAIR.sub(
82
+ lambda m: (
83
+ f'{m.group(1)}{m.group(2)}"[redacted]"'
84
+ if redactor.is_secret_key(m.group(1)[1:-1])
85
+ else m.group(0)
86
+ ),
87
+ text,
88
+ )
89
+ data = text.encode("utf-8")
90
+ return data
91
+
92
+
93
+ def _with_header(headers: list[tuple[bytes, bytes]], name: bytes, value: str) -> None:
94
+ """Append ``value`` to header ``name`` (comma-joined) or add it, in place."""
95
+ if not value:
96
+ return
97
+ for i, (key, old) in enumerate(headers):
98
+ if key.lower() == name:
99
+ headers[i] = (key, old + b", " + value.encode("latin-1"))
100
+ return
101
+ headers.append((name, value.encode("latin-1")))
102
+
103
+
104
+ def _badge_candidate(status: int, headers: list[tuple[bytes, bytes]]) -> bool:
105
+ if not ui.badge_allowed(
106
+ status,
107
+ _header(headers, b"content-type"),
108
+ content_encoding=_header(headers, b"content-encoding"),
109
+ csp=", ".join(
110
+ v.decode("latin-1") for k, v in headers if k.lower() == b"content-security-policy"
111
+ ),
112
+ ):
113
+ return False
114
+ if _header(headers, b"content-disposition") or _header(headers, b"last-modified"):
115
+ return False # a file (FileResponse / StaticFiles / download), not a rendered page
116
+ length = _header(headers, b"content-length")
117
+ if length is None:
118
+ return True # decided on the first body message (``more_body``)
119
+ try:
120
+ return int(length) <= ui.BADGE_MAX_BYTES
121
+ except ValueError:
122
+ return False
123
+
124
+
125
+ class RedlineMiddleware:
126
+ def __init__(
127
+ self,
128
+ app: Any,
129
+ recorder: Recorder | None = None,
130
+ prefix: str = "/_redline",
131
+ debug: Callable[[], bool] | None = None,
132
+ force_ok: Callable[[Any], bool] | None = None,
133
+ ) -> None:
134
+ self.app = app
135
+ self.recorder = recorder
136
+ self.prefix = prefix
137
+ self.debug = debug or (lambda: False)
138
+ #: ``force_ok(scope)``: may this request force profiling (``X-Redline-Profile``,
139
+ #: ``?_redline=profile``)? Called only when such a flag is present.
140
+ self.force_ok = force_ok or (lambda scope: self.debug())
141
+
142
+ async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
143
+ if scope["type"] != "http":
144
+ await self.app(scope, receive, send)
145
+ return
146
+ recorder = self.recorder or get_recorder()
147
+ path: str = scope["path"]
148
+ if not recorder.should_record(path):
149
+ await self.app(scope, receive, send)
150
+ return
151
+
152
+ settings = recorder.settings
153
+ headers = list(scope.get("headers") or [])
154
+ query = scope.get("query_string", b"")
155
+ client = scope.get("client")
156
+ builder = recorder.start_trace(
157
+ framework="fastapi",
158
+ method=scope["method"],
159
+ path=path,
160
+ headers=headers,
161
+ query=query,
162
+ client=client[0] if client else None,
163
+ profile_requested=_wants_profile(headers, query) and self.force_ok(scope),
164
+ )
165
+ root_path = scope.get("root_path", "")
166
+ limit = settings.max_body_bytes
167
+ req = _Capture(limit) if settings.capture_request_body else None
168
+ res = _Capture(limit)
169
+ capture_res = settings.capture_response_body
170
+ state: dict[str, Any] = {"status": None, "headers": [], "done": False}
171
+ server_timing = builder.active and ui.server_timing_enabled(settings, self.debug())
172
+ badge = builder.active and ui.badge_enabled(settings, self.debug())
173
+ held: list[Any] = [] # a response start held back for badge injection
174
+
175
+ def finalize(status: int | None = None, error: BaseException | None = None) -> None:
176
+ if state["done"]:
177
+ return
178
+ state["done"] = True
179
+ if req is not None:
180
+ builder.req_body = req.value(recorder, _header(headers, b"content-type"))
181
+ route = scope.get("route")
182
+ route_path = getattr(route, "path", None)
183
+ if route_path is not None:
184
+ mounted = scope.get("root_path", "")
185
+ if mounted != root_path and mounted.startswith(root_path):
186
+ route_path = mounted[len(root_path) :] + route_path
187
+ resp_headers = state["headers"]
188
+ body = None
189
+ if capture_res and _header(resp_headers, b"content-encoding") is None:
190
+ body = res.value(recorder, _header(resp_headers, b"content-type"))
191
+ try:
192
+ recorder.finish_trace(
193
+ builder,
194
+ status=status or state["status"] or 500,
195
+ headers=resp_headers,
196
+ body=body,
197
+ size=res.total,
198
+ route=route_path,
199
+ view=_endpoint_name(scope.get("endpoint")),
200
+ error=error,
201
+ path_params=scope.get("path_params") or None,
202
+ )
203
+ except Exception: # pragma: no cover - recording must never break a request
204
+ import logging
205
+
206
+ logging.getLogger("redline").exception("Redline failed to finish a trace")
207
+
208
+ async def recv() -> Any:
209
+ message = await receive()
210
+ if req is not None and message["type"] == "http.request":
211
+ chunk = message.get("body", b"")
212
+ if chunk:
213
+ req.add(chunk)
214
+ return message
215
+
216
+ def inject(start: Any, message: Any, chunk: bytes) -> tuple[Any, Any]:
217
+ summary = ui.badge_summary(builder, start["status"])
218
+ snippet = ui.badge_snippet(builder.id, root_path + self.prefix, summary)
219
+ body = ui.inject_badge(chunk, snippet)
220
+ if body is None:
221
+ return start, message
222
+ out = [(k, v) for k, v in start["headers"] if k.lower() != b"content-length"]
223
+ if len(out) != len(start["headers"]):
224
+ out.append((b"content-length", str(len(body)).encode("latin-1")))
225
+ return {**start, "headers": out}, {**message, "body": body}
226
+
227
+ async def send_wrapper(message: Any) -> None:
228
+ kind = message["type"]
229
+ if kind == "http.response.start":
230
+ state["status"] = message["status"]
231
+ state["headers"] = list(message.get("headers") or [])
232
+ if server_timing:
233
+ out = list(state["headers"])
234
+ _with_header(out, b"server-timing", builder.server_timing())
235
+ message = {**message, "headers": out}
236
+ if badge and _badge_candidate(message["status"], state["headers"]):
237
+ held.append(message)
238
+ return
239
+ elif kind == "http.response.body":
240
+ chunk = message.get("body", b"")
241
+ if capture_res:
242
+ res.add(chunk)
243
+ else:
244
+ res.total += len(chunk)
245
+ if held:
246
+ start = held.pop()
247
+ if not message.get("more_body", False) and len(chunk) <= ui.BADGE_MAX_BYTES:
248
+ start, message = inject(start, message, chunk)
249
+ await send(start)
250
+ await send(message)
251
+ if kind == "http.response.body" and not message.get("more_body", False):
252
+ finalize()
253
+
254
+ try:
255
+ async with track_request(builder): # event-loop lag monitor (blocking spans)
256
+ await self.app(scope, recv, send_wrapper)
257
+ except Exception as exc:
258
+ finalize(error=exc)
259
+ raise
260
+ except BaseException: # cancellation: the client went away
261
+ finalize(status=state["status"] or 499)
262
+ raise
263
+ else:
264
+ finalize()
@@ -0,0 +1,228 @@
1
+ """Access control for the Redline UI and API.
2
+
3
+ Every Redline request goes through :func:`redline_guard`, which asks the core access gate
4
+ (:class:`redline_core.access.Access`, ``CONTRACT.md`` §18) – the ``access`` setting picks
5
+ the policy (``local`` by default; ``token``, ``basic``, ``framework``, ``custom``,
6
+ ``open``). Passing ``install(dependencies=[...])`` implies ``access="framework"``: your
7
+ dependencies decide, and FastAPI runs them as usual.
8
+
9
+ The helpers below are kept for explicit use as dependencies:
10
+
11
+ * :func:`local_only` / :func:`loopback_guard` – loopback clients addressing a loopback
12
+ ``Host`` (against DNS rebinding);
13
+ * :func:`csrf_guard` – the Origin / ``Sec-Fetch-Site`` check (the gate applies it anyway);
14
+ * :class:`RedlineHTTPError` – what the guards raise; the Redline router renders it (and
15
+ any ``HTTPException`` from your own auth dependencies) in the API's error shape,
16
+ ``{"error": {"code": ..., "message": ...}}``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import inspect
22
+ from collections.abc import Awaitable, Callable, Iterable, Sequence
23
+ from typing import Any
24
+ from urllib.parse import urlsplit
25
+
26
+ from fastapi import Depends, HTTPException, Request
27
+ from fastapi.params import Depends as DependsParam
28
+
29
+ from redline_core.access import (
30
+ LOCAL_HOST_NAMES,
31
+ Access,
32
+ Decision,
33
+ RequestInfo,
34
+ is_local_host,
35
+ is_loopback,
36
+ )
37
+
38
+ __all__ = [
39
+ "LOCAL_HOST_NAMES",
40
+ "RedlineHTTPError",
41
+ "csrf_guard",
42
+ "is_local_host_header",
43
+ "local_only",
44
+ "loopback_guard",
45
+ "redline_guard",
46
+ "refuse_false",
47
+ "request_info",
48
+ ]
49
+
50
+ _SAFE = {"GET", "HEAD", "OPTIONS"}
51
+ _TESTCLIENT = "testclient" # Starlette's TestClient reports this as the client address
52
+
53
+
54
+ class RedlineHTTPError(HTTPException):
55
+ """An ``HTTPException`` with a machine-readable ``code`` for the error shape."""
56
+
57
+ def __init__(
58
+ self,
59
+ status_code: int,
60
+ code: str,
61
+ message: str,
62
+ headers: dict[str, str] | None = None,
63
+ extra: dict | None = None,
64
+ ) -> None:
65
+ super().__init__(status_code=status_code, detail=message, headers=headers)
66
+ self.code = code
67
+ self.message = message
68
+ self.extra = dict(extra or {})
69
+
70
+ @classmethod
71
+ def from_decision(cls, decision: Decision) -> RedlineHTTPError:
72
+ return cls(
73
+ decision.status,
74
+ decision.code,
75
+ decision.message,
76
+ dict(decision.headers) or None,
77
+ dict(decision.extra),
78
+ )
79
+
80
+
81
+ def _client_ip(request: Request) -> str | None:
82
+ host = request.client.host if request.client else None
83
+ return "127.0.0.1" if host == _TESTCLIENT else host
84
+
85
+
86
+ def _is_loopback(host: str | None) -> bool:
87
+ """Whether a *client address* is loopback (``None`` is a unix socket)."""
88
+ return host == _TESTCLIENT or is_loopback(host)
89
+
90
+
91
+ def is_local_host_header(header: str | None, extra: Iterable[str] = ()) -> bool:
92
+ """Whether a ``Host`` header names this machine's loopback interface (see
93
+ :func:`redline_core.access.is_local_host`)."""
94
+ return is_local_host(header, extra)
95
+
96
+
97
+ def scope_info(scope: Any) -> RequestInfo:
98
+ """A :class:`RequestInfo` from a raw ASGI scope (for the profile-flag check)."""
99
+ client = scope.get("client")
100
+ host = client[0] if client else None
101
+ return RequestInfo.build(
102
+ method=scope.get("method", "GET"),
103
+ path="",
104
+ client_ip="127.0.0.1" if host == _TESTCLIENT else host,
105
+ headers=scope.get("headers") or [],
106
+ scheme=scope.get("scheme", "http"),
107
+ mount=scope.get("root_path", "") or "/",
108
+ )
109
+
110
+
111
+ def request_info(request: Request, mount: str) -> RequestInfo:
112
+ """The framework-neutral description of a request under ``mount`` (e.g. ``/_redline``)."""
113
+ root = request.scope.get("root_path", "") or ""
114
+ path = request.scope.get("path", "")
115
+ if root and path.startswith(root + "/"):
116
+ path = path[len(root) :]
117
+ rel = path[len(mount) :] if path.startswith(mount) else path
118
+ return RequestInfo.build(
119
+ method=request.method,
120
+ path=rel.lstrip("/"),
121
+ client_ip=_client_ip(request),
122
+ headers=request.headers.items(),
123
+ scheme=request.url.scheme,
124
+ mount=f"{root.rstrip('/')}{mount}",
125
+ )
126
+
127
+
128
+ def redline_guard(access: Access, mount: str) -> Callable[[Request], Awaitable[None]]:
129
+ """The dependency every Redline route runs first: the core access check."""
130
+
131
+ async def guard(request: Request) -> None:
132
+ decision = access.check(request_info(request, mount), framework_ok=lambda: True)
133
+ if not decision.allowed:
134
+ raise RedlineHTTPError.from_decision(decision)
135
+
136
+ guard.__name__ = "redline_guard"
137
+ return guard
138
+
139
+
140
+ def refuse_false(dependencies: Sequence[Any]) -> Any:
141
+ """One dependency that runs the user's ``dependencies`` and refuses with 403 when any
142
+ of them *returns* ``False``.
143
+
144
+ Raising ``HTTPException`` is the normal way for a FastAPI dependency to refuse; this
145
+ catches the easy mistake of a check that returns a bool (``return user.is_admin``),
146
+ which FastAPI would otherwise ignore – letting everyone in. Only ``False`` itself
147
+ refuses: ``None`` (no return value) and other values pass. The dependencies are
148
+ resolved by FastAPI as usual (sub-dependencies, ``yield``, ``Security`` scopes).
149
+ """
150
+ params = [
151
+ inspect.Parameter(
152
+ f"_redline_dep_{i}",
153
+ inspect.Parameter.KEYWORD_ONLY,
154
+ default=d if isinstance(d, DependsParam) else Depends(d),
155
+ )
156
+ for i, d in enumerate(dependencies)
157
+ ]
158
+
159
+ async def check(**results: Any) -> None:
160
+ if any(value is False for value in results.values()):
161
+ raise RedlineHTTPError(
162
+ 403, "forbidden", "Redline refused this request (a dependency returned False)."
163
+ )
164
+
165
+ check.__signature__ = inspect.Signature(params) # type: ignore[attr-defined]
166
+ check.__name__ = "redline_dependencies"
167
+ return Depends(check)
168
+
169
+
170
+ def loopback_guard(*, allowed_hosts: Iterable[str] = ()) -> Callable[[Request], Awaitable[None]]:
171
+ """A dependency allowing only loopback clients that address a loopback ``Host``.
172
+
173
+ Checking the ``Host`` header defeats **DNS rebinding**: a web page on
174
+ ``evil.example`` that re-points its own DNS name at ``127.0.0.1`` can make your
175
+ browser send requests to the local server, but they carry ``Host: evil.example``.
176
+ ``allowed_hosts`` adds names (e.g. a ``/etc/hosts`` alias such as ``"devbox"``);
177
+ ports are ignored. (The ``access="local"`` policy does the same with the
178
+ ``allowed_hosts`` setting.)
179
+ """
180
+ extra = frozenset(h.strip().lower() for h in allowed_hosts)
181
+
182
+ async def guard(request: Request) -> None:
183
+ client = request.client.host if request.client else None
184
+ if not _is_loopback(client):
185
+ raise RedlineHTTPError(
186
+ 403,
187
+ "forbidden",
188
+ "Redline is only reachable from localhost. Pass "
189
+ "install(app, dependencies=[Depends(your_auth)]) to expose it.",
190
+ )
191
+ if not is_local_host(request.headers.get("host"), extra):
192
+ raise RedlineHTTPError(
193
+ 403,
194
+ "forbidden",
195
+ "Redline only answers requests addressed to localhost, 127.0.0.1 or [::1] "
196
+ "(this request's Host header names another site, as in a DNS-rebinding "
197
+ "attack). Use loopback_guard(allowed_hosts=[...]) to allow another name.",
198
+ )
199
+
200
+ return guard
201
+
202
+
203
+ #: Loopback clients, loopback ``Host`` names (what ``access="local"`` enforces).
204
+ local_only = loopback_guard()
205
+ local_only.__name__ = "local_only"
206
+ local_only.__doc__ = "Allow only loopback clients addressing a loopback Host."
207
+
208
+
209
+ async def csrf_guard(request: Request) -> None:
210
+ """Reject cross-site requests to the mutating endpoints.
211
+
212
+ Browsers attach an ``Origin`` header to every cross-origin and every same-origin
213
+ non-GET request, so it must match the host the request was addressed to. Requests
214
+ without ``Origin`` (curl, scripts) cannot be forged by a web page and are allowed.
215
+ ``Sec-Fetch-Site: cross-site`` is rejected too. The access gate applies the same rule
216
+ to every Redline request.
217
+ """
218
+ if request.method in _SAFE:
219
+ return
220
+ if request.headers.get("sec-fetch-site") in ("cross-site", "same-site"):
221
+ raise RedlineHTTPError(403, "csrf_failed", "Cross-site request blocked.")
222
+ origin = request.headers.get("origin")
223
+ if origin is None:
224
+ return
225
+ allowed = {h.strip().lower() for h in (request.headers.get("host"),) if h}
226
+ netloc = urlsplit(origin).netloc.lower() if origin != "null" else ""
227
+ if netloc not in allowed:
228
+ raise RedlineHTTPError(403, "csrf_failed", "Cross-origin request blocked.")
@@ -0,0 +1,108 @@
1
+ """Response serialization as ``serializer`` spans.
2
+
3
+ FastAPI validates and serializes the endpoint's return value in
4
+ ``fastapi.routing.serialize_response`` (response-model validation + Pydantic dump, or
5
+ ``jsonable_encoder`` without a response model). That one coroutine is wrapped: the span
6
+ covers all of it, nested models included. The wrapper is looked up as a module global by
7
+ FastAPI's request handler, so patching the attribute is enough; it is defensive about the
8
+ signature (keyword-only today, but only ``field``/``response_content`` are read, by name)
9
+ and costs one ``ContextVar.get()`` outside a traced request.
10
+
11
+ The span's callsite (a stack walk) is captured only when the serialization is over
12
+ ``slow_serializer_ms``, i.e. when a ``slow_serializer`` issue can point at it: the endpoint
13
+ has already returned when FastAPI serializes, so a fast span's stack says nothing useful.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import functools
19
+ import time
20
+ from typing import Any, get_args, get_origin
21
+
22
+ from redline_core import record_span
23
+ from redline_core.recorder import _trace
24
+
25
+ __all__ = ["instrument_serialization", "uninstrument_serialization"]
26
+
27
+ _perf = time.perf_counter
28
+ _originals: dict[str, Any] = {}
29
+ _SEQUENCES = (list, tuple, set, frozenset)
30
+
31
+
32
+ def _type_name(annotation: Any) -> str | None:
33
+ """``Order`` for ``Order``, ``list[Order]``, ``Optional[Order]``…"""
34
+ if annotation is None:
35
+ return None
36
+ args = [a for a in get_args(annotation) if a is not type(None)]
37
+ if get_origin(annotation) is not None and args:
38
+ return _type_name(args[0])
39
+ return getattr(annotation, "__name__", None)
40
+
41
+
42
+ def _describe(field: Any, content: Any) -> tuple[str, int | None]:
43
+ """``(model name, item count or None)`` for one serialization."""
44
+ items = len(content) if isinstance(content, _SEQUENCES) else None
45
+ annotation = getattr(field, "type_", None)
46
+ if annotation is None:
47
+ info = getattr(field, "field_info", None)
48
+ annotation = getattr(info, "annotation", None)
49
+ name = _type_name(annotation)
50
+ if name is None:
51
+ sample = content[0] if items else content
52
+ name = type(sample).__name__ if items != 0 else "list"
53
+ return name, items
54
+
55
+
56
+ def instrument_serialization() -> bool:
57
+ """Patch ``fastapi.routing.serialize_response``; ``False`` if it is not there. Idempotent."""
58
+ try:
59
+ from fastapi import routing
60
+ except ImportError: # pragma: no cover - fastapi is this package's dependency
61
+ return False
62
+ original = getattr(routing, "serialize_response", None)
63
+ if original is None:
64
+ return False
65
+ if _originals:
66
+ return True
67
+ _originals["serialize_response"] = original
68
+
69
+ @functools.wraps(original)
70
+ async def serialize_response(*args: Any, **kwargs: Any) -> Any:
71
+ builder = _trace.get()
72
+ if builder is None:
73
+ return await original(*args, **kwargs)
74
+ start = _perf()
75
+ try:
76
+ return await original(*args, **kwargs)
77
+ finally:
78
+ duration = _perf() - start
79
+ try:
80
+ name, items = _describe(kwargs.get("field"), kwargs.get("response_content"))
81
+ except Exception:
82
+ name, items = "response", None
83
+ slow = duration * 1000.0 >= builder.recorder.settings.slow_serializer_ms
84
+ record_span(
85
+ "serializer",
86
+ f"serialize {name}",
87
+ start,
88
+ duration,
89
+ {
90
+ "library": "pydantic",
91
+ "serializer": name,
92
+ "items": items,
93
+ "many": items is not None,
94
+ },
95
+ slow,
96
+ )
97
+
98
+ routing.serialize_response = serialize_response
99
+ return True
100
+
101
+
102
+ def uninstrument_serialization() -> None:
103
+ """Undo :func:`instrument_serialization`."""
104
+ if not _originals:
105
+ return
106
+ from fastapi import routing
107
+
108
+ routing.serialize_response = _originals.pop("serialize_response")
@@ -0,0 +1,19 @@
1
+ """Compatibility shim: the implementation lives in :mod:`redline_core.contrib.sqlalchemy`."""
2
+
3
+ from redline_core.contrib.sqlalchemy import (
4
+ EngineLike,
5
+ alias_for,
6
+ build_executor,
7
+ instrument_engine,
8
+ name_engines,
9
+ vendor_of,
10
+ )
11
+
12
+ __all__ = [
13
+ "EngineLike",
14
+ "alias_for",
15
+ "build_executor",
16
+ "instrument_engine",
17
+ "name_engines",
18
+ "vendor_of",
19
+ ]