perf(organizations): cache and instrument API responses

This commit is contained in:
2026-05-14 16:14:45 +02:00
parent 6d1ec2e55c
commit 5fdd23ecc0
13 changed files with 603 additions and 33 deletions

View File

@@ -4,15 +4,20 @@ Core middleware components.
Provides Request ID tracking and other cross-cutting concerns.
"""
import json
import logging
import threading
import time
import uuid
from django.db import connection
from django.middleware.csrf import CsrfViewMiddleware
from django.template.response import ContentNotRenderedError
from django.urls import Resolver404, resolve
from django.utils.deprecation import MiddlewareMixin
logger = logging.getLogger(__name__)
organization_metrics_logger = logging.getLogger("organizations.api.metrics")
# Thread-local storage for request context
_request_context = threading.local()
@@ -170,3 +175,102 @@ class RequestLoggingMiddleware(MiddlewareMixin):
},
)
return response
class OrganizationApiMetricsMiddleware:
"""Emit focused request metrics for API endpoints that serve organizations."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not self._should_log(request):
return self.get_response(request)
db_query_count = 0
db_duration_seconds = 0.0
started_at = time.perf_counter()
response = None
error = None
def execute_with_metrics(execute, sql, params, many, context):
nonlocal db_query_count, db_duration_seconds
db_query_count += 1
query_started_at = time.perf_counter()
try:
return execute(sql, params, many, context)
finally:
db_duration_seconds += time.perf_counter() - query_started_at
try:
with connection.execute_wrapper(execute_with_metrics):
response = self.get_response(request)
return response
except Exception as exc:
error = exc.__class__.__name__
raise
finally:
duration_seconds = time.perf_counter() - started_at
self._log_metrics(
request=request,
response=response,
error=error,
duration_seconds=duration_seconds,
db_query_count=db_query_count,
db_duration_seconds=db_duration_seconds,
)
@staticmethod
def _should_log(request) -> bool:
path = getattr(request, "path_info", "") or getattr(request, "path", "")
if not path.startswith("/api/"):
return False
return "organizations" in path.strip("/").split("/")
@classmethod
def _log_metrics(
cls,
*,
request,
response,
error: str | None,
duration_seconds: float,
db_query_count: int,
db_duration_seconds: float,
) -> None:
metrics = {
"request_id": getattr(request, "request_id", None),
"method": request.method,
"path": getattr(request, "path_info", "") or request.path,
"status_code": getattr(response, "status_code", 500),
"duration_ms": round(duration_seconds * 1000, 2),
"db_query_count": db_query_count,
"db_duration_ms": round(db_duration_seconds * 1000, 2),
"response_size_bytes": cls._response_size(response),
"cache": response.get("X-Cache") if response is not None else None,
"user_id": cls._user_id(request),
"query_keys": sorted(request.GET.keys()),
"error": error,
}
organization_metrics_logger.info(
"organization_api_metrics %s",
json.dumps(metrics, ensure_ascii=False, sort_keys=True),
extra={"organization_api_metrics": metrics},
)
@staticmethod
def _response_size(response) -> int | None:
if response is None or getattr(response, "streaming", False):
return None
try:
return len(response.content)
except (AttributeError, ContentNotRenderedError):
return None
@staticmethod
def _user_id(request) -> int | None:
user = getattr(request, "user", None)
if user is None or not getattr(user, "is_authenticated", False):
return None
return getattr(user, "id", None)