Files
remnawave-bedolaga-telegram…/app/webapi/routes/logs.py
T
Fringg 1f0fef114b refactor: complete structlog migration with contextvars, kwargs, and logging hardening
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding
  via structlog contextvars (aiogram) and http_method/http_path (FastAPI)
- Use bound_contextvars() context manager instead of clear_contextvars()
  to safely restore previous state instead of wiping all context
- Register ContextVarsMiddleware as outermost middleware (before GlobalError)
  so all error logs include user context
- Replace structlog.get_logger() with structlog.get_logger(__name__) across
  270 calls in 265 files for meaningful logger names
- Switch wrapper_class from BoundLogger to make_filtering_bound_logger()
  for pre-processor level filtering (performance optimization)
- Migrate 1411 %-style positional arg logger calls to structlog kwargs
  style across 161 files via AST script
- Migrate log_rotation_service.py from stdlib logging to structlog
- Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES
  and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking
  to Telegram notifications and general log files
- Fix LoggingMiddleware: add from_user null-safety for channel posts,
  switch time.time() to time.monotonic() for duration measurement
- Remove duplicate logger assignments in purchase.py, config.py,
  inline.py, and admin/payments.py
2026-02-16 09:18:12 +03:00

270 lines
9.4 KiB
Python

"""Маршруты административного API для просмотра логов."""
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, Security
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.ticket import TicketCRUD
from app.services.monitoring_service import monitoring_service
from ..dependencies import get_db_session, require_api_token
from ..schemas.logs import (
MonitoringLogEntry,
MonitoringLogListResponse,
MonitoringLogTypeListResponse,
SupportAuditActionsResponse,
SupportAuditLogEntry,
SupportAuditLogListResponse,
SystemLogFullResponse,
SystemLogPreviewResponse,
)
router = APIRouter()
logger = structlog.get_logger(__name__)
SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT = 4000
SYSTEM_LOG_PREVIEW_LIMIT_MAX = 20000
def _resolve_system_log_path() -> Path:
path = Path(settings.LOG_FILE)
if not path.is_absolute():
path = Path.cwd() / path
return path
async def _read_system_log(path: Path) -> tuple[str, int, float | None]:
def _read() -> tuple[str, int, float]:
content = path.read_text(encoding='utf-8', errors='ignore')
stats = path.stat()
return content, stats.st_size, stats.st_mtime
return await run_in_threadpool(_read)
def _format_timestamp(timestamp: float | None) -> datetime | None:
if timestamp is None:
return None
return datetime.fromtimestamp(timestamp, tz=UTC)
@router.get('/system', response_model=SystemLogPreviewResponse)
async def get_system_log_preview(
_: Any = Security(require_api_token),
preview_limit: int = Query(
SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT,
ge=500,
le=SYSTEM_LOG_PREVIEW_LIMIT_MAX,
description='Количество символов предпросмотра от конца файла',
),
) -> SystemLogPreviewResponse:
"""Получить предпросмотр системного лог-файла бота."""
log_path = _resolve_system_log_path()
if not log_path.exists() or not log_path.is_file():
return SystemLogPreviewResponse(
path=str(log_path),
exists=False,
updated_at=None,
size_bytes=0,
size_chars=0,
preview='',
preview_chars=0,
preview_truncated=False,
download_url='/logs/system/download',
)
try:
content, size_bytes, mtime = await _read_system_log(log_path)
except FileNotFoundError:
logger.warning('Лог-файл исчез во время чтения', log_path=log_path)
return SystemLogPreviewResponse(
path=str(log_path),
exists=False,
updated_at=None,
size_bytes=0,
size_chars=0,
preview='',
preview_chars=0,
preview_truncated=False,
download_url='/logs/system/download',
)
except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения
logger.error('Ошибка чтения лог-файла', log_path=log_path, error=error)
raise HTTPException(status_code=500, detail='Не удалось прочитать лог-файл') from error
preview_text = content[-preview_limit:] if preview_limit > 0 else ''
truncated = len(content) > len(preview_text)
return SystemLogPreviewResponse(
path=str(log_path),
exists=True,
updated_at=_format_timestamp(mtime),
size_bytes=size_bytes,
size_chars=len(content),
preview=preview_text,
preview_chars=len(preview_text),
preview_truncated=truncated,
download_url='/logs/system/download',
)
@router.get('/system/download')
async def download_system_log(
_: Any = Security(require_api_token),
) -> FileResponse:
"""Скачать полный лог-файл бота."""
log_path = _resolve_system_log_path()
if not log_path.exists() or not log_path.is_file():
raise HTTPException(status_code=404, detail='Лог-файл не найден')
try:
return FileResponse(
log_path,
media_type='text/plain',
filename=log_path.name,
)
except Exception as error: # pragma: no cover - защита от неожиданных ошибок отдачи файла
logger.error('Ошибка отправки лог-файла', log_path=log_path, error=error)
raise HTTPException(status_code=500, detail='Не удалось отправить лог-файл') from error
@router.get('/system/full', response_model=SystemLogFullResponse)
async def get_system_log_full(
_: Any = Security(require_api_token),
) -> SystemLogFullResponse:
"""Получить полный системный лог-файл бота."""
log_path = _resolve_system_log_path()
if not log_path.exists() or not log_path.is_file():
raise HTTPException(status_code=404, detail='Лог-файл не найден')
try:
content, size_bytes, mtime = await _read_system_log(log_path)
except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения
logger.error('Ошибка чтения лог-файла', log_path=log_path, error=error)
raise HTTPException(status_code=500, detail='Не удалось прочитать лог-файл') from error
return SystemLogFullResponse(
path=str(log_path),
exists=True,
updated_at=_format_timestamp(mtime),
size_bytes=size_bytes,
size_chars=len(content),
content=content,
)
@router.get('/monitoring', response_model=MonitoringLogListResponse)
async def list_monitoring_logs(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200, description='Количество записей на странице'),
offset: int = Query(0, ge=0, description='Смещение от начала списка'),
event_type: str | None = Query(
default=None,
max_length=100,
description='Фильтр по типу события',
),
) -> MonitoringLogListResponse:
"""Получить список логов мониторинга с пагинацией."""
per_page = limit
page = (offset // per_page) + 1
raw_logs = await monitoring_service.get_monitoring_logs(
db,
event_type=event_type,
page=page,
per_page=per_page,
)
total = await monitoring_service.get_monitoring_logs_count(db, event_type=event_type)
return MonitoringLogListResponse(
total=total,
limit=limit,
offset=offset,
items=[MonitoringLogEntry(**entry) for entry in raw_logs],
)
@router.get('/monitoring/event-types', response_model=MonitoringLogTypeListResponse)
async def list_monitoring_event_types(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MonitoringLogTypeListResponse:
"""Получить список доступных типов событий мониторинга."""
event_types = await monitoring_service.get_monitoring_event_types(db)
return MonitoringLogTypeListResponse(items=event_types)
@router.get('/support', response_model=SupportAuditLogListResponse)
async def list_support_audit_logs(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200, description='Количество записей на странице'),
offset: int = Query(0, ge=0, description='Смещение от начала списка'),
action: str | None = Query(
default=None,
max_length=50,
description='Фильтр по типу действия модератора',
),
) -> SupportAuditLogListResponse:
"""Получить список аудита действий модераторов поддержки."""
logs = await TicketCRUD.list_support_audit(
db,
limit=limit,
offset=offset,
action=action,
)
total = await TicketCRUD.count_support_audit(db, action=action)
return SupportAuditLogListResponse(
total=total,
limit=limit,
offset=offset,
items=[
SupportAuditLogEntry(
id=log.id,
actor_user_id=log.actor_user_id,
actor_telegram_id=log.actor_telegram_id,
is_moderator=log.is_moderator,
action=log.action,
ticket_id=log.ticket_id,
target_user_id=log.target_user_id,
details=log.details,
created_at=log.created_at,
)
for log in logs
],
)
@router.get('/support/actions', response_model=SupportAuditActionsResponse)
async def list_support_audit_actions(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> SupportAuditActionsResponse:
"""Получить список действий, доступных в аудите поддержки."""
actions = await TicketCRUD.list_support_audit_actions(db)
return SupportAuditActionsResponse(items=actions)