Version 0.4.0
All checks were successful
Run linters on applied template / Python 3.13 lint and build (push) Successful in 1m40s

Changes:
- put ObservabilityMiddleware before ExceptionHandlerMiddleware to avoid repetative code
- add application startup and last metrics update metrics along with CPU usage metric and threads count
- move host and port to new uvicorn section at config along with new reload and forwarded_allow_ips
- add request_id and remove trace_id/span_id generation if tracing is disabled
- move logging logic from utils to observability
- pass trace_id/span_id in HEX form
This commit is contained in:
2026-01-03 11:01:43 +03:00
parent b8acb017fd
commit 53f14a8624
26 changed files with 901 additions and 730 deletions

View File

@@ -3,13 +3,17 @@
import itertools
import traceback
from fastapi import FastAPI, HTTPException, Request
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from opentelemetry import trace
from opentelemetry.sdk.metrics import Counter
from opentelemetry.semconv.attributes import exception_attributes, http_attributes, url_attributes
from starlette.exceptions import HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
from {{project_slug}}.dependencies import logger_dep
from {{project_slug}}.exceptions.mapper import ExceptionMapper
from .observability import ObservableException
from {{project_slug}}.observability.utils import URLsMapper
class ExceptionHandlerMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-public-methods
@@ -22,31 +26,67 @@ class ExceptionHandlerMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few
def __init__(
self,
app: FastAPI,
debug: list[bool],
*,
debug: bool,
exception_mapper: ExceptionMapper,
):
urls_mapper: URLsMapper,
errors_metric: Counter,
): # pylint: disable=too-many-arguments
"""Passing debug as a list with single element is a hack to be able to change the value on the fly."""
super().__init__(app)
self._debug = debug
self._mapper = exception_mapper
self._exception_mapper = exception_mapper
self._urls_mapper = urls_mapper
self._metric = errors_metric
async def dispatch(self, request: Request, call_next):
try:
return await call_next(request)
except Exception as exc: # pylint: disable=broad-except
additional_headers: dict[str, str] | None = None
if isinstance(exc, ObservableException):
additional_headers = {"X-Trace-Id": exc.trace_id, "X-Span-Id": str(exc.span_id)}
exc = exc.__cause__
status_code = 500
detail = "exception occured"
if isinstance(exc, HandlerNotFoundError):
exc = exc.__cause__
cause = exc
if isinstance(exc, HTTPException):
status_code = exc.status_code # pylint: disable=no-member
detail = exc.detail # pylint: disable=no-member
if exc.__cause__ is not None:
cause = exc.__cause__
if self._debug[0]:
if (res := self._mapper.apply_if_known(exc)) is not None:
self._metric.add(
1,
{
http_attributes.HTTP_REQUEST_METHOD: request.method,
url_attributes.URL_PATH: self._urls_mapper.map(request.method, request.url.path),
"error_type": type(cause).__qualname__,
http_attributes.HTTP_RESPONSE_STATUS_CODE: status_code,
},
)
span = trace.get_current_span()
logger = logger_dep.from_request(request)
is_known = self._exception_mapper.is_known(cause)
span.record_exception(cause, {"is_known": is_known})
if is_known:
log_func = logger.aerror
else:
log_func = logger.aexception
await log_func("failed to handle request", error_type=type(cause).__name__)
span.set_status(trace.StatusCode.ERROR)
span.set_attributes(
{
exception_attributes.EXCEPTION_TYPE: type(cause).__name__,
exception_attributes.EXCEPTION_MESSAGE: repr(cause),
http_attributes.HTTP_RESPONSE_STATUS_CODE: status_code,
}
)
if self._debug:
if (res := self._exception_mapper.apply_if_known(exc)) is not None:
response = res
else:
response = JSONResponse(
@@ -56,18 +96,23 @@ class ExceptionHandlerMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few
"error": str(exc),
"error_type": type(exc).__name__,
"path": request.url.path,
"params": request.url.query,
"query_params": request.url.query,
"tracebacks": _get_tracebacks(exc),
},
status_code=status_code,
)
else:
response = self._mapper.apply(exc)
if additional_headers is not None:
response.headers.update(additional_headers)
response = self._exception_mapper.apply(exc)
return response
class HandlerNotFoundError(Exception):
"""Exception to raise on FastAPI 404 handler (only for situation when no handler was found for request).
Guranteed to have `.__cause__` as its parent exception.
"""
def _get_tracebacks(exc: Exception) -> list[list[str]]:
tracebacks: list[list[str]] = []
while exc is not None: