Files
MathApp/config/middleware.py

92 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
میان‌افزارِ سبکِ لاگِ درخواست‌های API — سطحِ پروژه.
⚠️ افزوده شده: پروژه چند logger پراکنده‌ی اپلیکیشنی داشت (accounts.services،
ai_tutor.views، ...) اما هیچ لاگِ یکپارچه‌ای در سطحِ پروژه برای *همه‌ی*
درخواست‌های API وجود نداشت؛ یعنی درخواست‌های عادی، ناموفق، یا الگوهای
مشکوک (مثلاً یک IP/کاربر که پشتِ سرِ هم درخواستِ ناموفق می‌فرسته) به‌صورتِ
یکدست قابلِ ردیابی نبودن. این میان‌افزار فقط یک خط لاگِ خلاصه به‌ازای هر
درخواستِ زیرِ /api/ ثبت می‌کنه: متد، مسیر، کدِ وضعیتِ پاسخ، مدت‌زمان، و
شناسه‌ی کاربرِ احرازهویت‌شده (اگه وجود داشته باشه).
نکاتِ امنیتی/حریمِ خصوصی:
- هیچ‌وقت بدنه‌ی درخواست/پاسخ، query string، هدرها (از جمله Authorization،
Idempotency-Key و غیره)، رمزعبور/OTP، access/refresh token، کلیدِ API، یا
سکرتِ webhook لاگ نمی‌شه — فقط request.path (بدونِ query string) و
متادیتای بی‌ضررِ بالا.
- در صورتِ هر خطای غیرمنتظره در خودِ فرآیندِ لاگ‌کردن (مثلاً یک مقدارِ
غیرمنتظره برای user)، این میان‌افزار هیچ‌وقت نباید جریانِ درخواست/پاسخ
را بشکنه؛ به همین خاطر کل بخشِ لاگ داخلِ try/except قرار گرفته.
- ⚠️ اصلاح: قبلاً اگه self.get_response(request) خودش یک exceptionِ
دست‌نخورده (unhandled) بالا می‌آورد، اون exception قبل از رسیدن به بلوکِ
لاگ از __call__ خارج می‌شد؛ یعنی خطاهای ۵۰۰ اصلاً لاگ نمی‌شدن. حالا
self.get_response در try/except قرار گرفته: در صورتِ exception، یک خطِ
لاگِ سطحِ ERROR (با متد/مسیر/مدت/user_id، بدونِ جزئیاتِ حساس) ثبت می‌شه
و بعد همون exceptionِ اصلی بدونِ تغییر دوباره raise می‌شه (تا رفتارِ
مدیریتِ خطای Django/DRF دست‌نخورده بمونه).
- ⚠️ اصلاحِ این دور: پیامِ لاگِ مسیرِ exception از یک placeholderِ واقعیِ
status=%s استفاده می‌کنه (هم‌سو با مسیرِ موفق)، و status=500 حالا از
طریقِ آرگومانِ status به _log_safely() صریحاً پاس داده می‌شه، نه به‌صورتِ
متنِ ثابت داخلِ رشته. این هم پیام رو با مسیرِ موفق یکدست می‌کنه و هم از
ناهماهنگیِ تعدادِ آرگومان‌های % formatting جلوگیری می‌کنه.
"""
import logging
import time
logger = logging.getLogger(__name__)
# فقط مسیرهای API لاگ می‌شن؛ درخواست‌های استاتیک/غیرِ API (که این پروژه
# اصلاً مسیرِ URL جداگانه‌ای براشون در config/urls.py تعریف نکرده) عمداً
# نادیده گرفته می‌شن تا لاگ شلوغ/بی‌فایده تولید نشه.
_LOGGED_PATH_PREFIX = '/api/'
def _user_id_for_log(request):
"""شناسه‌ی کاربرِ احرازهویت‌شده برای لاگ، یا None (کاربرِ ناشناس/بدونِ user)."""
user = getattr(request, 'user', None)
return user.id if getattr(user, 'is_authenticated', False) else None
class APIRequestLoggingMiddleware:
"""میان‌افزارِ Django برای لاگِ خلاصه‌ی هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر)."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not request.path.startswith(_LOGGED_PATH_PREFIX):
return self.get_response(request)
start = time.monotonic()
try:
response = self.get_response(request)
except Exception:
self._log_safely(
logger.error,
'api_request method=%s path=%s status=%s duration_ms=%.1f user_id=%s (unhandled exception)',
request, start, status=500,
)
raise
self._log_safely(
logger.info,
'api_request method=%s path=%s status=%s duration_ms=%.1f user_id=%s',
request, start, response.status_code,
)
return response
def _log_safely(self, log_func, message, request, start, status=None):
# لاگ‌کردن هیچ‌وقت نباید خودش دلیلِ شکستِ یک درخواست بشه (چه در
# مسیرِ موفق، چه در مسیرِ exception)؛ به همین خاطر کل بخشِ لاگ
# داخلِ try/except قرار گرفته.
try:
duration_ms = (time.monotonic() - start) * 1000
if status is None:
log_func(message, request.method, request.path, duration_ms, _user_id_for_log(request))
else:
log_func(message, request.method, request.path, status, duration_ms, _user_id_for_log(request))
except Exception:
logger.exception('api_request logging failed')