Coverage for src/asgi_profiler/middleware.py: 95%
99 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-22 16:59 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-22 16:59 +0000
1"""Request capture."""
3from __future__ import annotations
5import logging
6import threading
7import time
8import uuid
9from typing import Any
11from starlette.types import ASGIApp, Message, Receive, Scope, Send
13from . import rules
14from .config import ProfilerConfig
15from .detectors import detect
16from .instrument import current_origin, current_queries
17from .models import Profile, Query
18from .routing import request_path, route_path, route_pattern
19from .storage import Storage
21logger = logging.getLogger("asgi_profiler")
24class ProfilerMiddleware:
25 """Pure ASGI, one `Profile` per HTTP request.
27 Pure ASGI rather than `BaseHTTPMiddleware` on purpose: the latter runs the
28 downstream app in a separate task, which breaks contextvar propagation --
29 exactly what query attribution depends on.
30 """
32 def __init__(
33 self,
34 app: ASGIApp,
35 storage: Storage,
36 config: ProfilerConfig | None = None,
37 ) -> None:
38 self.app = app
39 self.storage = storage
40 self.config = config or ProfilerConfig()
41 self._excludes = self.config.build_excludes()
42 self._patterns = self.config.compiled_patterns()
43 self._excludes_key = self._exclude_key()
44 self._redacted = {h.lower() for h in self.config.redacted_headers}
46 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
47 if scope["type"] != "http":
48 await self.app(scope, receive, send)
49 return
51 original_root = scope.get("root_path", "")
52 full_path = request_path(scope)
53 # Match against the app-relative path: `mount_path`, `exclude_paths`
54 # and the regexes are written against the application's own routing
55 # table, not against whatever prefix it is deployed under.
56 allowed = self._path_allows(route_path(scope))
57 if not allowed and not rules.any_includes(): 57 ↛ 62line 57 didn't jump to line 62 because the condition on line 57 was never true
58 # Nothing in this process can flip that decision later, so skip
59 # before allocating a profile or capturing a single query. The
60 # moment one `@profiler_include` exists anywhere, this shortcut
61 # stops being sound and the decision moves below the response.
62 await self.app(scope, receive, send)
63 return
65 profile = Profile(
66 id=uuid.uuid4().hex[:12],
67 method=scope.get("method", "GET"),
68 path=full_path,
69 query_string=scope.get("query_string", b"").decode("latin-1"),
70 client=self._client(scope),
71 )
72 if self.config.capture_headers:
73 profile.request_headers = self._headers(scope.get("headers", []))
75 header_name = self.config.response_header
76 stamp = header_name.lower().encode("latin-1") if header_name else None
78 queries: list[Query] = []
79 token = current_queries.set(queries)
80 started = time.perf_counter()
81 # Both halves of what a statement needs to place itself: when the
82 # request began, and which thread the event loop is on. This runs on
83 # the loop thread by definition -- an ASGI app is only ever called
84 # from it -- so `get_ident()` here *is* the loop's thread.
85 origin_token = current_origin.set((started, threading.get_ident()))
87 async def send_wrapper(message: Message) -> None:
88 if message["type"] == "http.response.start":
89 profile.status_code = message["status"]
90 if stamp is not None:
91 message = {
92 **message,
93 "headers": [
94 *message.get("headers", []),
95 (stamp, profile.id.encode("latin-1")),
96 ],
97 }
98 if self.config.capture_headers:
99 profile.response_headers = self._headers(message.get("headers", []))
100 await send(message)
102 try:
103 await self.app(scope, receive, send_wrapper)
104 except Exception:
105 # An unhandled exception is turned into a 500 by
106 # ServerErrorMiddleware, which sits *outside* us -- so we never see
107 # the http.response.start. Record it ourselves and re-raise.
108 if not profile.status_code: 108 ↛ 110line 108 didn't jump to line 110 because the condition on line 108 was always true
109 profile.status_code = 500
110 raise
111 finally:
112 profile.duration_ms = (time.perf_counter() - started) * 1000
113 # A snapshot, not the live list: a fire-and-forget task inherits
114 # this context and would otherwise keep appending after the
115 # counters below are frozen, leaving the detail page showing
116 # "0 queries" above a list of statements.
117 profile.queries = list(queries)
118 profile.route = route_pattern(scope, original_root)
119 profile.finalise()
120 # After the response has gone out, so the cost of concluding
121 # things about a request is never paid by the person waiting for
122 # it. `detect` swallows its own errors for the same reason
123 # `_store` does.
124 profile.problems = detect(profile, self.config.detector_settings())
125 current_queries.reset(token)
126 current_origin.reset(origin_token)
127 # Guard rather than an early `return`: a bare return inside
128 # `finally` swallows whatever exception is in flight, so a handler
129 # that raised would have been reported as a clean response.
130 if self._keep(scope, allowed):
131 self._store(profile)
133 def _store(self, profile: Profile) -> None:
134 try:
135 self.storage.add(profile)
136 except Exception: # pragma: no cover - a wedged backend
137 # The response has already gone out. A full disk or a locked
138 # database must not turn every successful request into a logged
139 # ASGI exception, and must never be able to take the application
140 # down -- a profiler is not load-bearing.
141 logger.warning("Could not record profile", exc_info=True)
143 # -- helpers ---------------------------------------------------------
144 def _keep(self, scope: Scope, allowed: bool) -> bool:
145 """The final verdict, once routing has named the endpoint.
147 A decorator on the endpoint is decisive in both directions -- it is the
148 most specific statement of intent available, so it outranks every
149 pattern. Only when there is no decorator does the path-based decision
150 made before routing stand.
151 """
152 decided = rules.endpoint_decision(scope)
153 if decided is not None:
154 return decided
155 return allowed
157 def _exclude_key(self) -> tuple[Any, ...]:
158 return (
159 self.config.mount_path,
160 tuple(self.config.exclude_paths),
161 self.config.include_regex,
162 self.config.exclude_regex,
163 )
165 def _path_allows(self, path: str) -> bool:
166 key = self._exclude_key()
167 if key != self._excludes_key:
168 self._excludes_key = key
169 self._excludes = self.config.build_excludes()
170 self._patterns = self.config.compiled_patterns()
171 include_re, exclude_re = self._patterns
172 return rules.path_allows(
173 path,
174 excludes=self._excludes,
175 include_re=include_re,
176 exclude_re=exclude_re,
177 )
179 def _headers(self, raw: Any) -> dict[str, str]:
180 out: dict[str, str] = {}
181 for key, value in raw:
182 name = key.decode("latin-1") if isinstance(key, bytes) else str(key)
183 name = name.lower()
184 if name in self._redacted:
185 out[name] = "<redacted>"
186 continue
187 out[name] = value.decode("latin-1") if isinstance(value, bytes) else str(value)
188 return out
190 @staticmethod
191 def _client(scope: Scope) -> str:
192 client = scope.get("client")
193 if not client: 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true
194 return ""
195 host, port = client
196 return f"{host}:{port}"