Option C oc_diagnostics
Loading...
Searching...
No Matches
fastapi_middleware.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_diagnostics/fastapi_middleware.py
4
5"""
6FastAPI / ASGI Diagnostics Middleware
7======================================
8
9Purpose
10-------
11Pure ASGI middleware that logs every HTTP request and response through
12``_dbg()``, providing structured per-request diagnostics: HTTP method,
13path, status code, client IP, and elapsed wall-clock time.
14
15Responsibilities
16----------------
17- Implement ``DiagnosticsMiddleware`` as a pure ASGI callable that wraps
18 any ASGI application without requiring ``starlette.BaseHTTPMiddleware``.
19- Log request entry at L2 (method, path, client IP).
20- Log User-Agent header at L3 and all request/response headers at L4.
21- Capture HTTP status code from the ``http.response.start`` ASGI event via
22 a lightweight ``send`` wrapper; no request or response bodies are buffered.
23- Measure and report wall-clock elapsed time in milliseconds.
24- Pass non-HTTP scopes (WebSocket, lifespan) through unchanged.
25- Log application-level exceptions at L2 before re-raising them.
26- Route all output through ``_dbg()`` using the configurable domain
27 (default: ``"HTTP"``).
28
29Diagnostics
30-----------
31Domain: HTTP
32Levels:
33 L2 — lifecycle (request start, response end, application errors)
34 L3 — semantic details (User-Agent, 4xx/5xx status class)
35 L4 — deep tracing (all request and response headers)
36
37Contracts
38---------
39- Must never suppress exceptions raised by the wrapped application;
40 errors are logged at L2 and then re-raised unchanged.
41- Must not buffer or modify request or response bodies.
42- Must pass non-HTTP ASGI scopes (WebSocket, lifespan) through unchanged.
43- Does not require FastAPI at import time; works with any ASGI framework.
44 Install the ``[fastapi]`` optional extra for FastAPI framework utilities::
45
46 pip install oc_diagnostics[fastapi]
47
48- The ``HTTP`` domain must be configured in the project's
49 ``diagnostics/debug_config.json`` with a non-zero level for log output
50 to appear (``DEBUG_LEVELS.get("HTTP", 0)`` defaults to disabled).
51"""
52
53import time
54
56from oc_diagnostics.unified_debug import _dbg
57
58
59# ============================================================
60# PUBLIC MIDDLEWARE CLASS
61# ============================================================
62
64 """
65 Pure ASGI middleware that logs HTTP requests and responses via ``_dbg()``.
66
67 Parameters
68 ----------
69 app : ASGI callable
70 The next ASGI application in the middleware stack.
71 domain : str, optional
72 Debug domain used for all log output. Defaults to ``"HTTP"``.
73 Must be configured in ``diagnostics/debug_config.json`` with a
74 non-zero level; unknown domains are silently filtered by ``_dbg()``.
75
76 Usage
77 -----
78 Add to a FastAPI application **after** all routes are registered::
79
80 from fastapi import FastAPI
81 from oc_diagnostics import DiagnosticsMiddleware
82
83 app = FastAPI()
84 app.add_middleware(DiagnosticsMiddleware)
85
86 With a custom domain::
87
88 app.add_middleware(DiagnosticsMiddleware, domain="API")
89
90 Enable output by adding the ``HTTP`` domain to ``diagnostics/debug_config.json``::
91
92 "HTTP": { "level": 2, "color": "blue" }
93
94 Level guide for this middleware:
95 L2 — request/response lifecycle lines (always recommended)
96 L3 — User-Agent, 4xx/5xx status class commentary
97 L4 — full request and response header dumps
98 """
99
100 def __init__(self, app, domain: str = "HTTP"):
101 self._app = app
102 self._domain = domain
103 _dbg(
104 self._domain,
105 "init",
106 f"DiagnosticsMiddleware registered (domain={domain!r})",
107 level=2,
108 )
109
110 async def __call__(self, scope, receive, send):
111 """
112 ASGI entry point.
113
114 HTTP scopes are intercepted and logged. All other scope types
115 (``"websocket"``, ``"lifespan"``) are passed through unchanged.
116 """
117 # --------------------------------------------------------
118 # Non-HTTP scopes: pass through without any logging
119 # --------------------------------------------------------
120 if scope["type"] != "http":
121 await self._app(scope, receive, send)
122 return
123
124 # --------------------------------------------------------
125 # Extract request metadata
126 # --------------------------------------------------------
127 func = "request"
128 method = scope.get("method", "?")
129 path = scope.get("path", "/")
130 query = scope.get("query_string", b"").decode("utf-8", errors="replace")
131 full_path = f"{path}?{query}" if query else path
132 client = scope.get("client")
133 client_ip = f"{client[0]}:{client[1]}" if client else "unknown"
134
135 # L2 — request start
136 _dbg(
137 self._domain,
138 func,
139 f"START {method} {full_path} client={client_ip}",
140 level=2,
141 )
142
143 # L3 — User-Agent header
144 try:
145 headers_raw = scope.get("headers", [])
146 header_map = {k.lower(): v for k, v in headers_raw}
147 user_agent = header_map.get(b"user-agent", b"").decode("utf-8", errors="replace")
148 if user_agent:
149 _dbg(self._domain, func, f"User-Agent: {user_agent}", level=3)
150 except Exception:
151 pass
152
153 # L4 — all request headers (FS-17: sensitive headers are redacted)
154 try:
155 for key, value in scope.get("headers", []):
156 header_name = key.decode("utf-8", errors="replace").lower()
157 display_value = (
158 "[REDACTED]"
159 if header_name in _ud.HTTP_REDACT_HEADERS
160 else value.decode("utf-8", errors="replace")
161 )
162 _dbg(
163 self._domain,
164 func,
165 f"req-header {header_name}: {display_value}",
166 level=4,
167 )
168 except Exception:
169 pass
170
171 # --------------------------------------------------------
172 # Wrap send() to capture status code and response headers
173 # --------------------------------------------------------
174 start = time.perf_counter()
175 status_holder = [None]
176
177 async def _send_with_capture(message):
178 if message["type"] == "http.response.start":
179 status_holder[0] = message.get("status")
180 # L4 — response headers (FS-17: sensitive headers are redacted)
181 try:
182 for key, value in message.get("headers", []):
183 header_name = key.decode("utf-8", errors="replace").lower()
184 display_value = (
185 "[REDACTED]"
186 if header_name in _ud.HTTP_REDACT_HEADERS
187 else value.decode("utf-8", errors="replace")
188 )
189 _dbg(
190 self._domain,
191 func,
192 f"resp-header {header_name}: {display_value}",
193 level=4,
194 )
195 except Exception:
196 pass
197 await send(message)
198
199 # --------------------------------------------------------
200 # Run wrapped application; log errors and re-raise
201 # --------------------------------------------------------
202 try:
203 await self._app(scope, receive, _send_with_capture)
204 except Exception as exc:
205 elapsed_ms = (time.perf_counter() - start) * 1000
206 _dbg(
207 self._domain,
208 func,
209 f"ERROR {method} {full_path} [{elapsed_ms:.1f}ms] "
210 f"{type(exc).__name__}: {exc}",
211 level=2,
212 )
213 raise
214
215 # --------------------------------------------------------
216 # L2 — request end
217 # --------------------------------------------------------
218 elapsed_ms = (time.perf_counter() - start) * 1000
219 status = status_holder[0] if status_holder[0] is not None else "?"
220 _dbg(
221 self._domain,
222 func,
223 f"END {method} {full_path} -> {status} [{elapsed_ms:.1f}ms]",
224 level=2,
225 )
226
227 # L3 — status class commentary
228 try:
229 if isinstance(status, int):
230 if status >= 500:
231 _dbg(self._domain, func, f"5xx server error: {status}", level=3)
232 elif status >= 400:
233 _dbg(self._domain, func, f"4xx client error: {status}", level=3)
234 except Exception:
235 pass