B22: прикладное логирование vector.* + администрирование в доках (по образцу РВС)
- backend/logging_setup.py: setup_logging() (stdout INFO + файл /tmp/vector/vector.log,
ротация 5×2 МБ, VectorLOGLEVEL/VectorLOGFILE/VectorLOGDIR), get_logger('vector.*')
- события: vector.analyze (матрасчёт с длительностью), vector.auth (login_ok/failed/
lockout), vector.audit (каждая запись), vector.contour (ok/failed/rejected),
vector.operations (operation_complete), vector.geocode (успех/timeout/ошибка)
- существующие логгеры переведены в namespace vector.* (geo, geo.osm, rules, water, admin)
- README §13а: таблица наблюдаемых событий + правила (без ПДн/секретов)
- SETUP_GUIDE: §15 RPO/RTO + backup≠restore, §16 grep по vector.*, §17 диагностика
по цепочкам (БД, 500, fallback-зоны, вход), §17а чек-лист передачи проекта
- DEPLOY.md: вместо устаревшего дубля — ссылка на актуальные доки + шпаргалка стенда
- тесты: 239 passed
This commit is contained in:
@@ -12,8 +12,11 @@ from typing import Optional
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.models import AuditChange, AuditEvent, SecuritySetting, User
|
||||
|
||||
log = get_logger('audit')
|
||||
|
||||
|
||||
def _client_meta(request) -> tuple[str | None, str | None]:
|
||||
if request is None:
|
||||
@@ -54,6 +57,9 @@ def audit_log(
|
||||
new_value=None if new is None else str(new),
|
||||
))
|
||||
db.commit()
|
||||
log.info('audit %s user=%s obj=%s/%s ip=%s', event_type,
|
||||
event.username or '—', object_type or '—', event.object_id or '—',
|
||||
event.ip_address or '—')
|
||||
|
||||
|
||||
def audit_retention_days(db: Session) -> int:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Единая настройка логирования ВЕКТОРа (B22).
|
||||
|
||||
Все модульные логгеры проекта — `logging.getLogger('vector.<модуль>')` —
|
||||
конфигурируются один раз при старте приложения (backend.main → setup_logging()).
|
||||
|
||||
Формат: время, уровень, логгер, сообщение. Вывод — stdout (контейнер),
|
||||
дополнительно файл /tmp/vector/vector.log внутри контейнера (ротация 5×2 МБ).
|
||||
|
||||
Наблюдаемые события (ключевые слова для grep по логам):
|
||||
vector.analyze — анализ: case_id, радиус, зоны, время расчёта
|
||||
vector.auth — login_ok / login_failed / lockout / logout
|
||||
vector.contour — отправка зон в КОНТУР (ok/failed)
|
||||
vector.admin — синхронизация закрытия кейса
|
||||
vector.audit — запись аудит-события (кто, что)
|
||||
vector.geo — геослой: PostGIS-режим, fallback на Overpass, ошибки
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_LOG_FORMAT = '%(asctime)s %(levelname)s %(name)s: %(message)s'
|
||||
_LOG_DATEFMT = '%Y-%m-%d %H:%M:%S'
|
||||
|
||||
# Порог вывода в stdout: INFO на сервере, DEBUG при VectorLOGLEVEL=debug
|
||||
_LEVEL = os.getenv('VectorLOGLEVEL', 'info').upper()
|
||||
|
||||
# Файл-хэндлер: по умолчанию включён (внутри контейнера /tmp/vector).
|
||||
_FILE_ENABLED = os.getenv('VectorLOGFILE', '1').strip().lower() not in {'0', 'false', 'no'}
|
||||
_FILE_DIR = Path(os.getenv('VectorLOGDIR', '/tmp/vector'))
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Конфигурация корневого пакета 'vector'. Идемпотентна."""
|
||||
root = logging.getLogger('vector')
|
||||
if getattr(root, '_vector_configured', False):
|
||||
return
|
||||
|
||||
root.setLevel(logging.DEBUG)
|
||||
fmt = logging.Formatter(_LOG_FORMAT, datefmt=_LOG_DATEFMT)
|
||||
|
||||
stream = logging.StreamHandler()
|
||||
stream.setLevel(getattr(logging, _LEVEL if _LEVEL in {'DEBUG', 'INFO', 'WARNING', 'ERROR'} else 'INFO'))
|
||||
stream.setFormatter(fmt)
|
||||
root.addHandler(stream)
|
||||
|
||||
if _FILE_ENABLED:
|
||||
try:
|
||||
_FILE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
_FILE_DIR / 'vector.log',
|
||||
maxBytes=2 * 1024 * 1024,
|
||||
backupCount=5,
|
||||
encoding='utf-8',
|
||||
)
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(fmt)
|
||||
root.addHandler(file_handler)
|
||||
except OSError:
|
||||
root.warning('Файл-лог недоступен (%s) — только stdout', _FILE_DIR)
|
||||
|
||||
# uvicorn-логи ошибок приложения (traceback-и) идут тем же форматом
|
||||
uvicorn_error = logging.getLogger('uvicorn.error')
|
||||
uvicorn_error.handlers.clear()
|
||||
uvicorn_error.propagate = True
|
||||
|
||||
root._vector_configured = True # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Модульный логгер: get_logger('analyze') → 'vector.analyze'."""
|
||||
short = name.removeprefix('vector.')
|
||||
return logging.getLogger(f'vector.{short}')
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -7,6 +8,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from backend.database import init_db
|
||||
from backend.logging_setup import setup_logging
|
||||
from backend.routers.admin import router as admin_router
|
||||
from backend.routers.admin_users import router as admin_users_router
|
||||
from backend.routers.operations import router as operations_router
|
||||
@@ -34,8 +36,12 @@ if '*' in cors_origins:
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
setup_logging()
|
||||
log = logging.getLogger('vector.app')
|
||||
log.info('Vector API стартует: CORS=%s', ','.join(cors_origins))
|
||||
init_db()
|
||||
yield
|
||||
log.info('Vector API остановлен')
|
||||
|
||||
|
||||
app = FastAPI(title='Vector API', version='0.1.0', lifespan=lifespan)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from backend.database import db
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.routers.auth import require_roles
|
||||
from backend.schemas import CaseListResponse, CaseResponse, CaseUpdate, DashboardResponse
|
||||
|
||||
log = logging.getLogger('vector.admin')
|
||||
log = get_logger('admin')
|
||||
|
||||
router = APIRouter(prefix='/api/v1/admin', tags=['admin'], dependencies=[Depends(require_roles(['admin']))])
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -8,6 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.database import db
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.routers.auth import require_roles
|
||||
from services.rules_analysis import rules_analyze
|
||||
from services.search_engine import (
|
||||
@@ -15,6 +18,8 @@ from services.search_engine import (
|
||||
build_search_model,
|
||||
)
|
||||
|
||||
log = get_logger('analyze')
|
||||
|
||||
router = APIRouter(prefix='/api/v1/analyze', tags=['analyze'])
|
||||
|
||||
|
||||
@@ -44,6 +49,8 @@ class AnalysisRequest(BaseModel):
|
||||
async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]:
|
||||
# B14: роутер — тонкая обёртка. Вся расчётная логика — в
|
||||
# services/search_engine.py::build_search_model (без DB/auth/HTTP).
|
||||
import time as _time
|
||||
_t0 = _time.monotonic()
|
||||
case_data: dict[str, Any] = {
|
||||
'age': payload.age,
|
||||
'gender': payload.gender,
|
||||
@@ -109,6 +116,13 @@ async def analyze_case(payload: AnalysisRequest) -> dict[str, Any]:
|
||||
result['case_id'] = str(payload.case_id) if payload.case_id else None
|
||||
result['analyzed_at'] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
log.info(
|
||||
'анализ case=%s радиус=%.1fкм срочность=%s зон=%d время=%.0fмс',
|
||||
payload.case_id or '—', result.get('search_radius_km') or 0,
|
||||
result.get('urgency'), len(result.get('primary_zones') or []),
|
||||
(_time.monotonic() - _t0) * 1000,
|
||||
)
|
||||
|
||||
if payload.case_id is not None:
|
||||
db.update_case(str(payload.case_id), analysis_log=result, status='analyzed')
|
||||
|
||||
|
||||
@@ -15,8 +15,11 @@ from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.models import AuthEvent, SecuritySetting, User, UserSession
|
||||
|
||||
log = get_logger('auth')
|
||||
|
||||
router = APIRouter(prefix='/api/v1/auth', tags=['auth'])
|
||||
security = HTTPBearer(auto_error=False)
|
||||
SECRET_KEY = os.getenv('JWT_SECRET', 'change-me-in-production')
|
||||
@@ -127,9 +130,15 @@ def _register_failed_login(db: Session, user: User, request: Request | None) ->
|
||||
user.failed_login_count = (user.failed_login_count or 0) + 1
|
||||
if user.failed_login_count >= max_failed:
|
||||
user.locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_min)
|
||||
log.warning('lockout user=%s неудачных=%d блокировка=%dмин ip=%s',
|
||||
user.username, user.failed_login_count, lockout_min,
|
||||
request.client.host if request and request.client else '—')
|
||||
_log_auth(db, 'lockout', user.username, user.id, request,
|
||||
{'failed_count': user.failed_login_count})
|
||||
else:
|
||||
log.warning('login_failed user=%s неудачных=%d ip=%s',
|
||||
user.username, user.failed_login_count,
|
||||
request.client.host if request and request.client else '—')
|
||||
_log_auth(db, 'login_failed', user.username, user.id, request,
|
||||
{'failed_count': user.failed_login_count})
|
||||
db.commit()
|
||||
@@ -371,6 +380,9 @@ def login(
|
||||
user.last_login = datetime.now(timezone.utc)
|
||||
_log_auth(db, 'login', user.username, user.id, request)
|
||||
db.commit()
|
||||
log.info('login_ok user=%s ip=%s',
|
||||
getattr(user, 'username', '—'),
|
||||
request.client.host if request.client else '—')
|
||||
|
||||
token = create_access_token(
|
||||
{'sub': user.username, 'role': user.role, 'sid': sid_secret},
|
||||
|
||||
@@ -21,9 +21,12 @@ from pydantic import BaseModel
|
||||
|
||||
from backend.audit import audit_log, can_access_unit
|
||||
from backend.database import get_db
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.models import SearchOperation, User
|
||||
from backend.routers.auth import get_current_user, require_permission
|
||||
|
||||
log = get_logger('contour')
|
||||
|
||||
router = APIRouter(prefix='/api/v1/operations', tags=['operations-contour'])
|
||||
|
||||
def _contour_url() -> str:
|
||||
@@ -147,11 +150,14 @@ def send_to_contour(
|
||||
headers={'Authorization': f'Bearer {_contour_token()}'},
|
||||
timeout=30.0)
|
||||
except httpx.HTTPError as e:
|
||||
log.warning('contour_send_failed op=%s url=%s error=%s', op.id, _contour_url(), e)
|
||||
audit_log(db, current_user, 'contour_send_failed', object_type='operation',
|
||||
object_id=str(op.id), request=request, details={'error': str(e)})
|
||||
raise HTTPException(status_code=502, detail=f'КОНТУР недоступен: {e}') from e
|
||||
|
||||
if resp.status_code >= 400:
|
||||
log.warning('contour_rejected op=%s status=%d body=%s',
|
||||
op.id, resp.status_code, resp.text[:200])
|
||||
audit_log(db, current_user, 'contour_send_failed', object_type='operation',
|
||||
object_id=str(op.id), request=request,
|
||||
details={'status': resp.status_code, 'body': resp.text[:500]})
|
||||
@@ -159,6 +165,8 @@ def send_to_contour(
|
||||
detail=f'КОНТУР отклонил импорт: {resp.status_code} {resp.text[:300]}')
|
||||
|
||||
result = resp.json()
|
||||
log.info('contour_send_ok op=%s зон=%d contour_op=%s',
|
||||
op.id, len(payload['zones']), op.contour_operation_id)
|
||||
audit_log(db, current_user, 'contour_send_ok', object_type='operation',
|
||||
object_id=str(op.id), request=request,
|
||||
details={'zones': result.get('zones_total'), 'status': result.get('status')})
|
||||
|
||||
@@ -11,15 +11,19 @@ Nominatim Usage Policy: User-Agent обязателен, ≤1 rps — прокс
|
||||
- Пункт ПЕРВЫМ словом.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.routers.auth import require_roles
|
||||
from fastapi import Depends
|
||||
|
||||
log = get_logger('geocode')
|
||||
|
||||
router = APIRouter(prefix='/api/v1/geocode', tags=['geocode'])
|
||||
|
||||
NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search'
|
||||
@@ -59,10 +63,14 @@ async def geocode(
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.TimeoutException:
|
||||
log.warning('geocode timeout q=%s', q.strip()[:100])
|
||||
raise HTTPException(status_code=504, detail='Сервис геокодирования не ответил')
|
||||
except Exception as e:
|
||||
log.warning('geocode error q=%s: %s', q.strip()[:100], e)
|
||||
raise HTTPException(status_code=502, detail=f'Ошибка геокодирования: {e}')
|
||||
|
||||
log.info('geocode q=%s результатов=%d', q.strip()[:100], len(data))
|
||||
|
||||
return {'results': [
|
||||
GeocodeResult(
|
||||
lat=float(item['lat']),
|
||||
|
||||
@@ -13,10 +13,13 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.audit import audit_log, visible_unit_ids
|
||||
from backend.logging_setup import get_logger
|
||||
from backend.database import get_db
|
||||
from backend.models import Case, SearchOperation, User
|
||||
from backend.routers.auth import get_current_user, require_permission
|
||||
|
||||
oplog = get_logger('operations')
|
||||
|
||||
router = APIRouter(prefix='/api/v1/operations', tags=['operations'])
|
||||
|
||||
ALLOWED_TRANSITIONS = {
|
||||
@@ -203,6 +206,9 @@ def complete_operation(
|
||||
|
||||
db.commit()
|
||||
|
||||
oplog.info('operation_complete op=%s case=%s исход=%s юзер=%s',
|
||||
op.id, case.id, 'найден живым' if payload.found_alive else 'найден погибшим',
|
||||
getattr(current_user, 'username', '—'))
|
||||
audit_log(db, current_user, 'operation_complete',
|
||||
object_type='search_operation', object_id=str(op.id),
|
||||
details={'case_id': str(case.id), 'found_alive': payload.found_alive})
|
||||
|
||||
@@ -29,7 +29,7 @@ from backend.database import db
|
||||
from backend.routers.auth import require_roles
|
||||
from services.geo_service import _overpass_circuit_open, query_overpass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger('vector.water')
|
||||
|
||||
router = APIRouter(prefix='/api/v1/water', tags=['water'],
|
||||
dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
|
||||
|
||||
Reference in New Issue
Block a user