Добавлен средний слой с логированием данных

This commit is contained in:
2026-07-19 14:28:09 +03:00
parent 9dbbefedd3
commit 1d95a70282
8 changed files with 297 additions and 19 deletions

View File

@@ -30,12 +30,12 @@ RACK_TEST_DATABASE='/tmp/rack/rack.sqlite'
# #
# KeyDb Database # KeyDb Database
# #
USE_KEYDB=1 RACK_USE_KEYDB=1
KEYDB_HOST='127.0.0.1' RACK_KEYDB_HOST='127.0.0.1'
KEYDB_PORT=6379 RACK_KEYDB_PORT=6379
KEYDB_DBINDEX=0 RACK_KEYDB_DBINDEX=0
KEYDB_PASSWORD='<change-me>' RACK_KEYDB_PASSWORD='<change-me>'
KEYDB_POOL_SIZE=4 RACK_KEYDB_POOL_SIZE=4
# #
# S3 Object Storage # S3 Object Storage

View File

@@ -11,9 +11,9 @@ services:
env_file : env_file :
- .env - .env
environment : environment :
POSTGRES_DB : ${rack_DATABASE_NAME} POSTGRES_DB : ${RACK_DATABASE_NAME}
POSTGRES_USER : ${rack_DATABASE_USERNAME} POSTGRES_USER : ${RACK_DATABASE_USERNAME}
POSTGRES_PASSWORD: ${rack_DATABASE_PASSWORD} POSTGRES_PASSWORD: ${RACK_DATABASE_PASSWORD}
ports : ports :
- "${rack_DATABASE_PORT:-5432}:5432" - "${rack_DATABASE_PORT:-5432}:5432"
volumes : volumes :
@@ -29,7 +29,7 @@ services:
env_file : env_file :
- .env - .env
ports : ports :
- "${rack_KEYDB_PORT:-6379}:6379" - "${RACK_KEYDB_PORT:-6379}:6379"
command : keydb-server --protected-mode no --save "" command : keydb-server --protected-mode no --save ""
volumes : volumes :
- keydb_data:/data - keydb_data:/data

View File

@@ -7,6 +7,7 @@
""" """
Описание класса AccountManager Описание класса AccountManager
""" """
from net.xeaf.rack.enums import AuthRejectReason from net.xeaf.rack.enums import AuthRejectReason
from net.xeaf.rack.models.account_model import AccountModel from net.xeaf.rack.models.account_model import AccountModel

View File

@@ -9,4 +9,5 @@
""" """
from .custom_exception_handler import custom_exception_handler from .custom_exception_handler import custom_exception_handler
from .custom_logging_middleware import CustomLoggingMiddleware
from .expiring_token_authentication import ExpiringTokenAuthentication from .expiring_token_authentication import ExpiringTokenAuthentication

View File

@@ -8,6 +8,8 @@
Описание функции custom_exception_handler Описание функции custom_exception_handler
""" """
import logging
from rest_framework import status from rest_framework import status
from rest_framework.exceptions import MethodNotAllowed from rest_framework.exceptions import MethodNotAllowed
from rest_framework.exceptions import NotAuthenticated from rest_framework.exceptions import NotAuthenticated
@@ -21,6 +23,7 @@ from net.xeaf.rack.models.responses import ErrorResponse
from net.xeaf.rack.utils.exceptions import ForbiddenException from net.xeaf.rack.utils.exceptions import ForbiddenException
from net.xeaf.rack.utils.exceptions import NotFoundException from net.xeaf.rack.utils.exceptions import NotFoundException
from net.xeaf.rack.utils.exceptions import UnauthorizedException from net.xeaf.rack.utils.exceptions import UnauthorizedException
from wsgiapp import settings
def custom_exception_handler(exc: CoreException | Exception, context: dict) -> Response: def custom_exception_handler(exc: CoreException | Exception, context: dict) -> Response:
@@ -39,7 +42,12 @@ def custom_exception_handler(exc: CoreException | Exception, context: dict) -> R
# Это наше исключение # Это наше исключение
if isinstance(exc, CoreException): if isinstance(exc, CoreException):
return ErrorResponse(status_code=exc.status_code, detail=exc.detail, meta=exc.meta, pure_status=exc.is_pure_status()) return _make_error_response(
status_code=exc.status_code,
detail=exc.detail,
meta=exc.meta,
pure_status=exc.is_pure_status(),
exc_info=exc)
# Проверка на стандартное исключение валидации # Проверка на стандартное исключение валидации
if isinstance(exc, ValidationError): if isinstance(exc, ValidationError):
@@ -47,18 +55,38 @@ def custom_exception_handler(exc: CoreException | Exception, context: dict) -> R
# Проверка на стандартное исключение авторизации # Проверка на стандартное исключение авторизации
if isinstance(exc, NotAuthenticated): if isinstance(exc, NotAuthenticated):
return ErrorResponse(status_code=status.HTTP_401_UNAUTHORIZED, detail=UnauthorizedException.Msg.DEFAULT_MESSAGE) return _make_error_response(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=UnauthorizedException.Msg.DEFAULT_MESSAGE,
meta=None,
pure_status=False,
exc_info=exc)
# Проверка на стандартное исключение прав доступа # Проверка на стандартное исключение прав доступа
if isinstance(exc, PermissionDenied): if isinstance(exc, PermissionDenied):
return ErrorResponse(status_code=status.HTTP_403_FORBIDDEN, detail=ForbiddenException.Msg.DEFAULT_MESSAGE) return _make_error_response(
status_code=status.HTTP_403_FORBIDDEN,
detail=ForbiddenException.Msg.DEFAULT_MESSAGE,
meta=None,
pure_status=False,
exc_info=exc)
# Проверка на стандартное исключение отсутствия реализации # Проверка на стандартное исключение отсутствия реализации
if isinstance(exc, MethodNotAllowed): if isinstance(exc, MethodNotAllowed):
return ErrorResponse(status_code=status.HTTP_404_NOT_FOUND, detail=NotFoundException.Msg.DEFAULT_MESSAGE) return _make_error_response(
status_code=status.HTTP_404_NOT_FOUND,
detail=NotFoundException.Msg.DEFAULT_MESSAGE,
meta=None,
pure_status=True,
exc_info=exc)
# Если ничего другое не прошло # Если ничего другое не прошло
return ErrorResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) return _make_error_response(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(exc),
meta=None,
pure_status=True,
exc_info=exc)
def _process_validation_exception(exc: ValidationError) -> ErrorResponse: def _process_validation_exception(exc: ValidationError) -> ErrorResponse:
@@ -70,18 +98,53 @@ def _process_validation_exception(exc: ValidationError) -> ErrorResponse:
:return: Ответ :return: Ответ
""" """
# Обработка списка
if isinstance(exc.detail, list): if isinstance(exc.detail, list):
if len(exc.detail) == 1: if len(exc.detail) == 1:
detail = str(exc.detail[0]) detail = str(exc.detail[0])
else: else:
detail = exc.detail detail = exc.detail
# Обработка словаря
elif isinstance(exc.detail, dict): elif isinstance(exc.detail, dict):
detail = {} detail = {}
for field, error_info in exc.detail.items(): for field, error_info in exc.detail.items():
if isinstance(error_info, list): if isinstance(error_info, list):
error = error_info[0] error = error_info[0]
detail[field] = str(error) detail[field] = str(error)
# Обработка остальных типов
else: else:
detail = str(exc.detail) detail = str(exc.detail)
return ErrorResponse(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) return _make_error_response(status_code=status.HTTP_400_BAD_REQUEST, detail=detail, meta=None, pure_status=False, exc_info=exc)
def _make_error_response(status_code: int, detail: list | dict | str | bool | None, meta: dict | None, pure_status: bool,
exc_info: Exception) -> ErrorResponse:
"""
Создает ответ с ошибкой
:param status_code: Код статуса
:param detail: Детали ошибки
:param meta: Дополнительная информация
:param pure_status: Признак отправки чистого кода состояния
:param exc_info: Исключение
:return: Ответ
"""
logger = logging.getLogger(settings.LOGGER_NAME)
if status_code == status.HTTP_400_BAD_REQUEST:
logger.debug(str(exc_info), exc_info=exc_info)
elif status_code == status.HTTP_401_UNAUTHORIZED:
logger.warning(str(exc_info), exc_info=exc_info)
elif status_code == status.HTTP_403_FORBIDDEN:
logger.warning(str(exc_info), exc_info=exc_info)
elif status_code == status.HTTP_404_NOT_FOUND:
logger.error(str(exc_info), exc_info=exc_info)
else:
logger.critical(str(exc_info), exc_info=exc_info)
return ErrorResponse(status_code=status_code, detail=detail, meta=meta, pure_status=pure_status)

View File

@@ -0,0 +1,207 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CustomLoggingMiddleware
"""
import json
import logging
from logging import Logger
from typing import Any
from django.conf import settings
from django.http import HttpRequest
from django.http import HttpResponse
from rest_framework import status
class CustomLoggingMiddleware:
"""
Класс для логирования запросов и ответов DRF
"""
DEFAULT_SENSITIVE_KEYS = {'password', 'old_password', 'new_password', 'token', 'access_token'}
""" Словарь ключей, которые нужно замаскировать в логах """
MAX_LOG_LENGTH = 2048
""" Максимальная длина объекта данных лога """
sensitive_keys: set = {}
""" Актуальный словарь ключей, которые нужно замаскировать в логах """
logger: Logger
""" Объект доступа к логгеру """
def __init__(self, get_response):
"""
Инициализация
"""
self.get_response = get_response
self.logger = logging.getLogger(settings.LOGGER_NAME)
self.sensitive_keys = set(self.DEFAULT_SENSITIVE_KEYS)
if settings.LOGGER_SENSITIVE_KEYS:
self.sensitive_keys.update(settings.LOGGER_SENSITIVE_KEYS)
def __call__(self, request: HttpRequest):
"""
Собираем данные запроса и отправляем в лог
"""
# Обрабатываем данные запроса
path = request.path
method = request.method
query_params = self._process_query_params(request)
# Передаем запрос дальше по конвейеру Django / DRF
response = self.get_response(request)
# Обрабатываем данные запроса, ответа и перехваченного исключения
body = self._process_body(request)
exc = getattr(request, "_raised_exception", None)
status_code = response.status_code
request_id = request.META.get('HTTP_X_REQUEST_ID', '')
response_content = self._process_response(response)
# Обрабатываем данные пользователя
user_id = ""
if hasattr(request, 'user'):
if request.user.is_authenticated:
user_id = str(request.user.id)
# Формируем единое сообщение для лога
log_parts = [
f"{status_code} [{method}] {path}"
]
if user_id:
log_parts.append(f"User ID: {user_id}")
if request_id:
log_parts.append(f"Request ID: {request_id}")
if query_params:
log_parts.append(f"Query Params: {query_params}")
if body:
log_parts.append(f"Request Body: {body}")
if response_content:
log_parts.append(f"Response Content: {response_content}")
log_message = "\n".join(log_parts) + "\n"
# Создаем и записываем лог
self._write_log(log_message, status_code, exc)
return response
def _process_query_params(self, request: HttpRequest) -> dict[str, Any]:
"""
Обрабатывает параметры запроса и маскирует конфиденциальные данные
:param request: Объект запроса
:return: Обработанные параметры запроса
"""
raw_query_params = dict(request.GET.lists())
return self._mask_sensitive_data(raw_query_params)
def _process_body(self, request: HttpRequest) -> str:
"""
Обрабатывает тело запроса и маскирует конфиденциальные данные
:param request: Объект запроса
:return: Обработанное тело запроса
"""
if request.body:
return self._process_data(request.body)
return ""
def _process_response(self, response: HttpResponse) -> str:
"""
Обрабатываем данные ответа
:param response: HttpResponse
:return: Строковое представление данных ответа
"""
if hasattr(response, 'content'):
return self._process_data(response.content)
return ""
def _process_data(self, data: Any) -> str:
"""
Обрабатывает данные запроса или ответа и возвращает их в виде строки
:param data: Данные запроса или ответа
:return: Обработанные данные в виде строки
"""
data_len = len(data)
if data_len > self.MAX_LOG_LENGTH:
return f"<Data too large to log (len={data_len})>"
try:
raw_response = data.decode('utf-8')
try:
data_json = json.loads(raw_response)
masked_response = self._mask_sensitive_data(data_json)
result = json.dumps(masked_response, ensure_ascii=False, indent=2)
except json.JSONDecodeError:
result = raw_response
except UnicodeDecodeError:
result = f"<Binary/Streaming Data (len={data_len})>"
return result
def _write_log(self, log_message: str, status_code: int, exc: Exception | None):
"""
Записывает лог с учетом исключения
:param log_message: Сообщение лога
:param status_code: Код статуса ответа
:param exc: Исключение
"""
if exc:
log_message += f"\nException: {str(exc)}"
if status_code == status.HTTP_400_BAD_REQUEST:
self.logger.debug(log_message, exc_info=exc)
elif status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]: # UNAUTHORIZED, FORBIDDEN
self.logger.warning(log_message, exc_info=exc)
elif status_code == status.HTTP_404_NOT_FOUND:
self.logger.error(log_message, exc_info=exc)
else:
self.logger.critical(log_message, exc_info=exc)
else:
self.logger.debug(log_message)
def _mask_sensitive_data(self, data: Any) -> Any:
"""
Рекурсивно маскирует конфиденциальные данные в словарях и списках
:param data: Данные для маскировки
:return: Маскированные данные
"""
if isinstance(data, dict):
return {
key: '***' if key.lower() in self.sensitive_keys else self._mask_sensitive_data(value)
for key, value in data.items()
}
elif isinstance(data, list):
return [self._mask_sensitive_data(item) for item in data]
return data

View File

@@ -17,6 +17,11 @@ from net.xeaf.rack.utils.settings.logger import LogDebugFilter
# #
LOGGER_NAME = Environment.get_str("LOGGER_NAME", "rack") LOGGER_NAME = Environment.get_str("LOGGER_NAME", "rack")
#
# Ключи полей, которые необходимо скрывать в журнале
#
LOGGER_SENSITIVE_KEYS = Environment.get_str_as_list("LOGGER_SENSITIVE_KEYS", "")
# #
# Уровень журналирования # Уровень журналирования
# #
@@ -61,7 +66,7 @@ LOGGING = {
# #
"main_file": { "main_file": {
"level": "INFO" if LOG_LEVEL == "DEBUG" else LOG_LEVEL, "level": "INFO" if LOG_LEVEL == "DEBUG" else LOG_LEVEL,
"class": "logging.handlers.RotatingFileHandler", "class": "concurrent_log_handler.ConcurrentRotatingFileHandler",
"filename": LOG_FILE, "filename": LOG_FILE,
"maxBytes": 1024 * 1024 * 10, "maxBytes": 1024 * 1024 * 10,
"backupCount": 5, "backupCount": 5,
@@ -70,11 +75,11 @@ LOGGING = {
}, },
# #
# Отладка # Лог отладки
# #
"debug_file": { "debug_file": {
"level": "DEBUG", "level": "DEBUG",
"class": "logging.handlers.RotatingFileHandler", "class": "concurrent_log_handler.ConcurrentRotatingFileHandler",
"filename": DEBUG_LOG_FILE, "filename": DEBUG_LOG_FILE,
"maxBytes": 1024 * 1024 * 10, "maxBytes": 1024 * 1024 * 10,
"backupCount": 5, "backupCount": 5,

View File

@@ -9,6 +9,7 @@
""" """
MIDDLEWARE = [ MIDDLEWARE = [
'net.xeaf.rack.utils.drf.CustomLoggingMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',