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:

View File

@@ -1,59 +1,43 @@
"""Observability middleware is defined here."""
import time
from random import randint
import uuid
import structlog
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi import FastAPI, Request
from opentelemetry import context as tracing_context
from opentelemetry import trace
from opentelemetry.semconv.attributes import exception_attributes, http_attributes, url_attributes
from opentelemetry.trace import NonRecordingSpan, Span, SpanContext, TraceFlags
from opentelemetry.semconv.attributes import http_attributes, url_attributes
from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags
from starlette.middleware.base import BaseHTTPMiddleware
from {{project_slug}}.dependencies import logger_dep
from {{project_slug}}.exceptions.mapper import ExceptionMapper
from {{project_slug}}.observability.metrics import Metrics
from {{project_slug}}.utils.observability import URLsMapper, get_handler_from_path
from {{project_slug}}.observability.utils import URLsMapper, get_tracing_headers
_tracer = trace.get_tracer_provider().get_tracer(__name__)
class ObservableException(RuntimeError):
"""Runtime Error with `trace_id` and `span_id` set. Guranteed to have `.__cause__` as its parent exception."""
def __init__(self, trace_id: str, span_id: int):
super().__init__()
self.trace_id = trace_id
self.span_id = span_id
class ObservabilityMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-public-methods
"""Middleware for global observability requests.
- Generate tracing span and adds response header 'X-Trace-Id' and X-Span-Id'
- Generate tracing span and adds response headers
'X-Trace-Id', 'X-Span-Id' (if tracing is configured) and 'X-Request-Id'
- Binds trace_id it to logger passing it in request state (`request.state.logger`)
- Collects metrics for Prometheus
In case when jaeger is not enabled, trace_id and span_id are generated randomly.
"""
def __init__(self, app: FastAPI, exception_mapper: ExceptionMapper, metrics: Metrics, urls_mapper: URLsMapper):
def __init__(self, app: FastAPI, metrics: Metrics, urls_mapper: URLsMapper):
super().__init__(app)
self._exception_mapper = exception_mapper
self._http_metrics = metrics.http
self._urls_mapper = urls_mapper
async def dispatch(self, request: Request, call_next):
logger = logger_dep.obtain(request)
logger = logger_dep.from_request(request)
_try_get_parent_span_id(request)
with _tracer.start_as_current_span("http-request", record_exception=False) as span:
trace_id = hex(span.get_span_context().trace_id or randint(1, 1 << 63))[2:]
span_id = span.get_span_context().span_id or randint(1, 1 << 31)
if trace_id == 0:
trace_id = format(randint(1, 1 << 63), "016x")
span_id = format(randint(1, 1 << 31), "032x")
logger = logger.bind(trace_id=trace_id, span_id=span_id)
with _tracer.start_as_current_span("http request") as span:
request_id = str(uuid.uuid4())
logger = logger.bind(request_id=request_id)
logger_dep.attach_to_request(request, logger)
span.set_attributes(
@@ -62,106 +46,46 @@ class ObservabilityMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-pu
url_attributes.URL_PATH: request.url.path,
url_attributes.URL_QUERY: str(request.query_params),
"request_client": request.client.host,
"request_id": request_id,
}
)
await logger.ainfo(
"handling request",
"http begin",
client=request.client.host,
path_params=request.path_params,
method=request.method,
url=str(request.url),
)
path_for_metric = self._urls_mapper.map(request.url.path)
path_for_metric = self._urls_mapper.map(request.method, request.url.path)
self._http_metrics.requests_started.add(1, {"method": request.method, "path": path_for_metric})
self._http_metrics.inflight_requests.add(1)
time_begin = time.monotonic()
try:
result = await call_next(request)
duration_seconds = time.monotonic() - time_begin
result = await call_next(request)
duration_seconds = time.monotonic() - time_begin
result.headers.update({"X-Trace-Id": trace_id, "X-Span-Id": str(span_id)})
await self._handle_success(
request=request,
result=result,
logger=logger,
span=span,
path_for_metric=path_for_metric,
duration_seconds=duration_seconds,
)
return result
except Exception as exc:
duration_seconds = time.monotonic() - time_begin
await self._handle_exception(
request=request, exc=exc, logger=logger, span=span, duration_seconds=duration_seconds
)
raise ObservableException(trace_id=trace_id, span_id=span_id) from exc
finally:
self._http_metrics.request_processing_duration.record(
duration_seconds, {"method": request.method, "path": path_for_metric}
)
result.headers.update({"X-Request-Id": request_id} | get_tracing_headers())
async def _handle_success( # pylint: disable=too-many-arguments
self,
*,
request: Request,
result: Response,
logger: structlog.stdlib.BoundLogger,
span: Span,
path_for_metric: str,
duration_seconds: float,
) -> None:
await logger.ainfo("request handled successfully", time_consumed=round(duration_seconds, 3))
self._http_metrics.requests_finished.add(
1, {"method": request.method, "path": path_for_metric, "status_code": result.status_code}
)
await logger.ainfo("http end", time_consumed=round(duration_seconds, 3), status_code=result.status_code)
self._http_metrics.requests_finished.add(
1,
{
http_attributes.HTTP_REQUEST_METHOD: request.method,
url_attributes.URL_PATH: path_for_metric,
http_attributes.HTTP_RESPONSE_STATUS_CODE: result.status_code,
},
)
self._http_metrics.inflight_requests.add(-1)
span.set_attribute(http_attributes.HTTP_RESPONSE_STATUS_CODE, result.status_code)
async def _handle_exception( # pylint: disable=too-many-arguments
self,
*,
request: Request,
exc: Exception,
logger: structlog.stdlib.BoundLogger,
span: Span,
duration_seconds: float,
) -> None:
cause = exc
status_code = 500
if isinstance(exc, HTTPException):
status_code = getattr(exc, "status_code")
if exc.__cause__ is not None:
cause = exc.__cause__
self._http_metrics.errors.add(
1,
{
"method": request.method,
"path": get_handler_from_path(request.url.path),
"error_type": type(cause).__name__,
"status_code": status_code,
},
)
span.record_exception(exc)
if self._exception_mapper.is_known(exc):
log_func = logger.aerror
else:
log_func = logger.aexception
await log_func(
"failed to handle request", time_consumed=round(duration_seconds, 3), error_type=type(exc).__name__
)
span.set_attributes(
{
exception_attributes.EXCEPTION_TYPE: type(exc).__name__,
exception_attributes.EXCEPTION_MESSAGE: repr(exc),
http_attributes.HTTP_RESPONSE_STATUS_CODE: status_code,
}
)
if result.status_code // 100 == 2:
span.set_status(trace.StatusCode.OK)
span.set_attribute(http_attributes.HTTP_RESPONSE_STATUS_CODE, result.status_code)
self._http_metrics.request_processing_duration.record(
duration_seconds, {"method": request.method, "path": path_for_metric}
)
return result
def _try_get_parent_span_id(request: Request) -> None:
@@ -171,11 +95,14 @@ def _try_get_parent_span_id(request: Request) -> None:
if trace_id_str is None or span_id_str is None:
return
if not trace_id_str.isnumeric() or not span_id_str.isnumeric():
if not trace_id_str.isalnum() or not span_id_str.isalnum():
return
span_context = SpanContext(
trace_id=int(trace_id_str), span_id=int(span_id_str), is_remote=True, trace_flags=TraceFlags(0x01)
)
try:
span_context = SpanContext(
trace_id=int(trace_id_str, 16), span_id=int(span_id_str, 16), is_remote=True, trace_flags=TraceFlags(0x01)
)
except Exception: # pylint: disable=broad-except
return
ctx = trace.set_span_in_context(NonRecordingSpan(span_context))
tracing_context.attach(ctx)