T10: Monitoring, Performance, Documentation & Environment Config — 38 tests, ruff clean, docs OK
- Extended health endpoint: DB+Redis+Storage+Worker checks with degraded status - Prometheus metrics endpoint: admin-only, text/plain format - Metrics: http_requests_total, db_pool_connections, arq_jobs_total - Structured JSON logging (structlog): timestamp, level, method, path, status, duration_ms, tenant_id - Performance: page_size max 100 enforced (422), streaming CSV export (StreamingResponse) - Scripts: seed_perf_data.py, check_indexes.py - Docs: admin-guide.md (Deploy, Backup, Restore, Env-Vars, Troubleshooting), api-overview.md - README updated: prod setup, API section, env profiles, admin-guide link - .env.example: added SECRET_KEY, STORAGE_PATH, SMTP_* vars - 38 new tests, full regression 564/564 pass (0 failures) - Ruff: all checks passed
This commit is contained in:
@@ -40,6 +40,20 @@ class Settings(BaseSettings):
|
||||
session_cookie_httponly: bool = True
|
||||
password_reset_expiry_hours: int = 1
|
||||
|
||||
# Storage
|
||||
storage_path: str = "/tmp"
|
||||
|
||||
# SMTP
|
||||
smtp_host: str = "localhost"
|
||||
smtp_port: int = 587
|
||||
smtp_username: str | None = None
|
||||
smtp_password: str | None = None
|
||||
smtp_from_email: str = "noreply@leocrm.local"
|
||||
smtp_use_tls: bool = True
|
||||
|
||||
# Secret Key (for signing, sessions, etc.)
|
||||
secret_key: str = "change-me-in-production-use-a-secure-random-string"
|
||||
|
||||
# CORS
|
||||
cors_origins: str = "http://localhost:5173,http://localhost:3000"
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Monitoring: Prometheus metrics, structured JSON logging, health checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from prometheus_client import (
|
||||
CollectorRegistry,
|
||||
Counter,
|
||||
Gauge,
|
||||
Histogram,
|
||||
generate_latest,
|
||||
)
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.db import get_engine
|
||||
|
||||
# ─── Prometheus Metrics Registry ───
|
||||
|
||||
REGISTRY = CollectorRegistry()
|
||||
|
||||
http_requests_total = Counter(
|
||||
"leocrm_http_requests_total",
|
||||
"Total HTTP requests by method, path, and status",
|
||||
["method", "path", "status"],
|
||||
registry=REGISTRY,
|
||||
)
|
||||
|
||||
http_request_duration_seconds = Histogram(
|
||||
"leocrm_http_request_duration_seconds",
|
||||
"HTTP request duration in seconds",
|
||||
["method", "path"],
|
||||
registry=REGISTRY,
|
||||
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
|
||||
)
|
||||
|
||||
db_pool_connections = Gauge(
|
||||
"leocrm_db_pool_connections",
|
||||
"Database connection pool size",
|
||||
registry=REGISTRY,
|
||||
)
|
||||
|
||||
arq_jobs_total = Counter(
|
||||
"leocrm_arq_jobs_total",
|
||||
"Total ARQ background jobs by function and status",
|
||||
["function", "status"],
|
||||
registry=REGISTRY,
|
||||
)
|
||||
|
||||
|
||||
# ─── Structured Logging (structlog) ───
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.JSONRenderer(),
|
||||
],
|
||||
wrapper_class=structlog.make_filtering_bound_logger(20), # INFO level
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger("leocrm")
|
||||
|
||||
|
||||
def get_logger(name: str = "leocrm") -> structlog.stdlib.BoundLogger:
|
||||
"""Get a structured logger instance."""
|
||||
return structlog.get_logger(name)
|
||||
|
||||
|
||||
def record_request(
|
||||
method: str,
|
||||
path: str,
|
||||
status_code: int,
|
||||
duration_ms: float,
|
||||
tenant_id: str | None = None,
|
||||
) -> None:
|
||||
"""Record an API request in metrics and structured log."""
|
||||
http_requests_total.labels(method=method, path=path, status=str(status_code)).inc()
|
||||
http_request_duration_seconds.labels(method=method, path=path).observe(
|
||||
duration_ms / 1000.0
|
||||
)
|
||||
logger.info(
|
||||
"api_request",
|
||||
method=method,
|
||||
path=path,
|
||||
status=status_code,
|
||||
duration_ms=round(duration_ms, 2),
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
|
||||
def record_error(
|
||||
event: str,
|
||||
method: str,
|
||||
path: str,
|
||||
status_code: int,
|
||||
error: str,
|
||||
traceback_str: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
) -> None:
|
||||
"""Log an error with stacktrace and request context."""
|
||||
logger.error(
|
||||
event=event,
|
||||
method=method,
|
||||
path=path,
|
||||
status=status_code,
|
||||
error=error,
|
||||
traceback=traceback_str,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
|
||||
def update_db_pool_gauge() -> None:
|
||||
"""Update the DB pool connections gauge from the current engine."""
|
||||
engine = get_engine()
|
||||
pool = engine.pool
|
||||
db_pool_connections.set(pool.size() + pool.checkedout())
|
||||
|
||||
|
||||
def record_arq_job(function: str, status: str) -> None:
|
||||
"""Record an ARQ background job completion."""
|
||||
arq_jobs_total.labels(function=function, status=status).inc()
|
||||
|
||||
|
||||
def generate_metrics() -> bytes:
|
||||
"""Generate Prometheus-formatted metrics output."""
|
||||
update_db_pool_gauge()
|
||||
return generate_latest(REGISTRY)
|
||||
|
||||
|
||||
# ─── Health Checks ───
|
||||
|
||||
|
||||
async def check_database() -> dict[str, Any]:
|
||||
"""Check database connectivity (async, no blocking I/O)."""
|
||||
try:
|
||||
engine = get_engine()
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(text("SELECT 1"))
|
||||
result.scalar()
|
||||
return {"status": "up", "latency_ms": 0}
|
||||
except Exception as e:
|
||||
return {"status": "down", "error": str(e)}
|
||||
|
||||
|
||||
async def check_redis() -> dict[str, Any]:
|
||||
"""Check Redis connectivity (async)."""
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
r = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
pong = await r.ping()
|
||||
await r.aclose()
|
||||
if pong:
|
||||
return {"status": "up", "latency_ms": 0}
|
||||
return {"status": "down", "error": "Redis returned False for PING"}
|
||||
except Exception as e:
|
||||
return {"status": "down", "error": str(e)}
|
||||
|
||||
|
||||
async def check_storage() -> dict[str, Any]:
|
||||
"""Check storage directory accessibility."""
|
||||
import os
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
storage_path = getattr(settings, "storage_path", "/tmp")
|
||||
try:
|
||||
if os.path.exists(storage_path) and os.access(storage_path, os.W_OK): # noqa: ASYNC240
|
||||
return {"status": "up", "path": storage_path}
|
||||
return {"status": "down", "error": f"Storage path {storage_path} not writable"}
|
||||
except Exception as e:
|
||||
return {"status": "down", "error": str(e)}
|
||||
|
||||
|
||||
async def check_worker() -> dict[str, Any]:
|
||||
"""Check if ARQ worker process is reachable (best-effort).
|
||||
In test/dev mode this checks if Redis is available for the worker queue.
|
||||
"""
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
r = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
# Check if arq queue key exists
|
||||
queue_length = await r.llen("arq:queue")
|
||||
await r.aclose()
|
||||
return {"status": "up", "queue_length": queue_length}
|
||||
except Exception as e:
|
||||
return {"status": "down", "error": str(e)}
|
||||
|
||||
|
||||
async def get_health_status() -> dict[str, Any]:
|
||||
"""Run all health checks and return aggregated status."""
|
||||
db_result = await check_database()
|
||||
redis_result = await check_redis()
|
||||
storage_result = await check_storage()
|
||||
worker_result = await check_worker()
|
||||
|
||||
all_up = all(
|
||||
c["status"] == "up"
|
||||
for c in [db_result, redis_result, storage_result, worker_result]
|
||||
)
|
||||
|
||||
if all_up:
|
||||
overall_status = "healthy"
|
||||
elif db_result["status"] == "down":
|
||||
overall_status = "degraded"
|
||||
else:
|
||||
overall_status = "degraded"
|
||||
|
||||
return {
|
||||
"status": overall_status,
|
||||
"version": "1.0.0",
|
||||
"checks": {
|
||||
"database": db_result,
|
||||
"redis": redis_result,
|
||||
"storage": storage_result,
|
||||
"worker": worker_result,
|
||||
},
|
||||
}
|
||||
+51
-1
@@ -1,15 +1,19 @@
|
||||
"""FastAPI application - LeoCRM backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.db import close_engine, get_engine
|
||||
from app.core.middleware import CSRFMiddleware
|
||||
from app.core.monitoring import record_error, record_request
|
||||
from app.core.service_container import get_container
|
||||
from app.plugins.registry import get_registry
|
||||
from app.routes import (
|
||||
@@ -19,6 +23,7 @@ from app.routes import (
|
||||
contacts,
|
||||
health,
|
||||
import_export,
|
||||
metrics,
|
||||
notifications,
|
||||
plugins,
|
||||
roles,
|
||||
@@ -28,6 +33,49 @@ from app.routes import (
|
||||
)
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""Structured logging + Prometheus metrics for every HTTP request."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
start_time = time.perf_counter()
|
||||
method = request.method
|
||||
path = request.url.path
|
||||
|
||||
# Extract tenant_id from session cookie if available (best-effort)
|
||||
tenant_id = None
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception as exc:
|
||||
duration_ms = (time.perf_counter() - start_time) * 1000
|
||||
tb_str = traceback.format_exc()
|
||||
record_error(
|
||||
event="unhandled_exception",
|
||||
method=method,
|
||||
path=path,
|
||||
status_code=500,
|
||||
error=str(exc),
|
||||
traceback_str=tb_str,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
raise
|
||||
|
||||
duration_ms = (time.perf_counter() - start_time) * 1000
|
||||
status_code = response.status_code
|
||||
|
||||
# Try to get tenant_id from response headers or request state
|
||||
# (set by auth middleware/dependency — best-effort, never log credentials)
|
||||
record_request(
|
||||
method=method,
|
||||
path=path,
|
||||
status_code=status_code,
|
||||
duration_ms=duration_ms,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: startup and shutdown."""
|
||||
@@ -59,8 +107,10 @@ def create_app() -> FastAPI:
|
||||
max_age=3600,
|
||||
)
|
||||
app.add_middleware(CSRFMiddleware)
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(metrics.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(roles.router)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Routes package."""
|
||||
|
||||
from app.routes import (
|
||||
metrics, # noqa: F401
|
||||
ai_copilot, # noqa: F401
|
||||
auth, # noqa: F401
|
||||
companies, # noqa: F401
|
||||
|
||||
+57
-9
@@ -71,18 +71,66 @@ async def export_companies(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Export companies as CSV or XLSX."""
|
||||
"""Export companies as CSV (streaming) or XLSX (buffered)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
|
||||
if format == "csv":
|
||||
csv_data = await company_service.export_companies_csv(
|
||||
db,
|
||||
tenant_id,
|
||||
industry=industry,
|
||||
search=search,
|
||||
)
|
||||
return Response(
|
||||
content=csv_data,
|
||||
from app.models.company import Company
|
||||
from sqlalchemy import select as sa_select
|
||||
from app.core.db import get_session_factory
|
||||
import csv
|
||||
import io
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
async def generate_csv():
|
||||
"""Async generator yielding CSV rows (streaming, not buffered).
|
||||
Creates its own DB session to avoid request-scoped session issues.
|
||||
"""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
header = [
|
||||
"id", "name", "account_number", "industry", "phone",
|
||||
"email", "website", "description", "created_at", "updated_at",
|
||||
]
|
||||
writer.writerow(header)
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
batch_size = 500
|
||||
offset = 0
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
while True:
|
||||
q = sa_select(Company).where(
|
||||
Company.tenant_id == tenant_id,
|
||||
Company.deleted_at.is_(None),
|
||||
)
|
||||
if industry:
|
||||
q = q.where(Company.industry == industry)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
q = q.where(Company.name.ilike(pattern))
|
||||
q = q.order_by(Company.name.asc()).offset(offset).limit(batch_size)
|
||||
result = await session.execute(q)
|
||||
companies = result.scalars().all()
|
||||
if not companies:
|
||||
break
|
||||
for c in companies:
|
||||
writer.writerow([
|
||||
str(c.id), c.name, c.account_number or "", c.industry or "",
|
||||
c.phone or "", c.email or "", c.website or "",
|
||||
c.description or "",
|
||||
c.created_at.isoformat() if c.created_at else "",
|
||||
c.updated_at.isoformat() if c.updated_at else "",
|
||||
])
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
offset += batch_size
|
||||
|
||||
return StreamingResponse(
|
||||
generate_csv(),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=companies.csv"},
|
||||
)
|
||||
|
||||
+107
-2
@@ -1,15 +1,21 @@
|
||||
"""Contact routes — CRUD, N:M, soft-delete, GDPR hard-delete."""
|
||||
"""Contact routes — CRUD, N:M, soft-delete, GDPR hard-delete, streaming CSV export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.auth import check_permission
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.models.contact import Contact
|
||||
from app.schemas.contact import ContactCreate, ContactUpdate
|
||||
from app.services import contact_service
|
||||
|
||||
@@ -26,7 +32,10 @@ async def list_contacts(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List contacts with pagination and optional search."""
|
||||
"""List contacts with pagination and optional search.
|
||||
|
||||
page_size is capped at 100 — values >100 return 422.
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
result = await contact_service.list_contacts(
|
||||
db,
|
||||
@@ -40,6 +49,102 @@ async def list_contacts(
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_contacts(
|
||||
format: str = Query("csv", pattern="^(csv)$"),
|
||||
search: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Stream contacts as CSV (not buffered — uses StreamingResponse).
|
||||
|
||||
For large exports (>1000 records), an ARQ background job should be started
|
||||
with a notification on completion. This endpoint streams directly for
|
||||
immediate download.
|
||||
"""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
from app.core.db import get_session_factory
|
||||
|
||||
async def generate_csv():
|
||||
"""Async generator yielding CSV rows one at a time (streaming).
|
||||
Creates its own DB session to avoid using the request-scoped session
|
||||
which gets closed after the endpoint function returns.
|
||||
"""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Write header row
|
||||
header = [
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
"phone",
|
||||
"mobile",
|
||||
"position",
|
||||
"department",
|
||||
"linkedin_url",
|
||||
"notes",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
writer.writerow(header)
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
# Stream rows in batches to avoid loading all into memory
|
||||
batch_size = 500
|
||||
offset = 0
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
while True:
|
||||
base = select(Contact).where(
|
||||
Contact.tenant_id == tenant_id,
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
base = base.where(
|
||||
(Contact.first_name.ilike(pattern))
|
||||
| (Contact.last_name.ilike(pattern))
|
||||
| (Contact.email.ilike(pattern))
|
||||
)
|
||||
base = base.order_by(Contact.last_name.asc()).offset(offset).limit(batch_size)
|
||||
result = await session.execute(base)
|
||||
contacts = result.scalars().all()
|
||||
if not contacts:
|
||||
break
|
||||
|
||||
for c in contacts:
|
||||
writer.writerow(
|
||||
[
|
||||
str(c.id),
|
||||
c.first_name,
|
||||
c.last_name,
|
||||
c.email or "",
|
||||
c.phone or "",
|
||||
c.mobile or "",
|
||||
c.position or "",
|
||||
c.department or "",
|
||||
c.linkedin_url or "",
|
||||
c.notes or "",
|
||||
c.created_at.isoformat() if c.created_at else "",
|
||||
c.updated_at.isoformat() if c.updated_at else "",
|
||||
]
|
||||
)
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
offset += batch_size
|
||||
|
||||
return StreamingResponse(
|
||||
generate_csv(),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=contacts.csv"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_contact(
|
||||
body: ContactCreate,
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
"""Health check endpoint."""
|
||||
"""Health check endpoint — extended with DB, Redis, storage, worker checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.core.monitoring import get_health_status
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/api/v1/health")
|
||||
async def health():
|
||||
"""Health check — no auth required."""
|
||||
return {"status": "ok", "version": "1.0.0"}
|
||||
"""Health check — no auth required.
|
||||
|
||||
Returns status (healthy/degraded) and individual checks for
|
||||
database, redis, storage, and worker.
|
||||
"""
|
||||
return await get_health_status()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Prometheus metrics endpoint — admin-only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from app.core.monitoring import generate_metrics
|
||||
from app.deps import require_admin
|
||||
|
||||
router = APIRouter(tags=["metrics"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/metrics",
|
||||
response_class=PlainTextResponse,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def metrics():
|
||||
"""Prometheus metrics endpoint.
|
||||
|
||||
Returns Prometheus-formatted text/plain metrics.
|
||||
Requires admin role — 403 for non-admin users.
|
||||
"""
|
||||
data = generate_metrics()
|
||||
return PlainTextResponse(
|
||||
content=data.decode("utf-8"),
|
||||
media_type="text/plain; version=0.0.4; charset=utf-8",
|
||||
)
|
||||
Reference in New Issue
Block a user