Add comprehensive structured logging with Loki + Grafana

Backend logging:
- Centralized JSON logging config with LOG_LEVEL env var
- Request logging middleware: user, method, path, status, duration, request_id
- Fixed all 9 silent except:pass blocks to log warnings with tracebacks
- Celery workers use same structured JSON format

Infrastructure:
- Loki 3.3.2 for log storage (30-day retention)
- Promtail 3.3.2 for Docker container log shipping
- Grafana 10.3.1 with auto-provisioned Loki datasource
- Grafana on port 3002 (admin/pedshub_grafana)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-11 02:53:54 +02:00
parent b32fc65236
commit d0518d0737
14 changed files with 289 additions and 10 deletions

View file

@ -51,5 +51,7 @@ class Settings(BaseSettings):
OIDC_SCOPES: str = "openid email profile" # space-separated OIDC_SCOPES: str = "openid email profile" # space-separated
OIDC_PROVIDER_NAME: str = "SSO" # Display name on login button OIDC_PROVIDER_NAME: str = "SSO" # Display name on login button
LOG_LEVEL: str = "INFO" # DEBUG, INFO, WARNING, ERROR
settings = Settings() settings = Settings()

View file

@ -0,0 +1,52 @@
"""Centralized structured JSON logging for backend and Celery workers."""
import logging
import logging.config
import sys
def setup_logging(level: str = "INFO"):
"""Configure all Python loggers to output structured JSON to stdout."""
try:
from pythonjsonlogger.json import JsonFormatter
formatter_class = "pythonjsonlogger.json.JsonFormatter"
formatter_fmt = "%(asctime)s %(name)s %(levelname)s %(message)s"
except ImportError:
# Fallback if python-json-logger not installed
formatter_class = "logging.Formatter"
formatter_fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
config = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"json": {
"class": formatter_class,
"format": formatter_fmt,
"datefmt": "%Y-%m-%dT%H:%M:%S",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "json",
},
},
"root": {
"level": level.upper(),
"handlers": ["console"],
},
"loggers": {
# Quieten noisy libraries
"uvicorn.access": {"level": "WARNING"},
"uvicorn.error": {"level": level.upper()},
"chromadb": {"level": "WARNING"},
"httpcore": {"level": "WARNING"},
"httpx": {"level": "WARNING"},
"celery": {"level": level.upper()},
"sqlalchemy.engine": {"level": "WARNING"},
},
}
logging.config.dictConfig(config)
logging.getLogger(__name__).debug("Logging configured: level=%s", level)

View file

@ -6,6 +6,10 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.config import settings from app.config import settings
from app.logging_config import setup_logging
# Configure structured JSON logging before anything else
setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses
from app.utils.auth import get_password_hash from app.utils.auth import get_password_hash
@ -513,6 +517,10 @@ app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
from app.utils.auth import TokenRefreshMiddleware from app.utils.auth import TokenRefreshMiddleware
app.add_middleware(TokenRefreshMiddleware) app.add_middleware(TokenRefreshMiddleware)
# Request logging middleware — logs every request with user, duration, status
from app.middleware.request_logging import RequestLoggingMiddleware
app.add_middleware(RequestLoggingMiddleware)
# Serve uploaded images as static files # Serve uploaded images as static files
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads") app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")

View file

View file

@ -0,0 +1,70 @@
"""Middleware that logs every HTTP request with user context and timing."""
import logging
import time
import uuid
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
logger = logging.getLogger("pedshub.requests")
# Paths to skip logging (noisy or uninteresting)
SKIP_PATHS = {"/api/health", "/favicon.ico", "/robots.txt"}
SKIP_PREFIXES = ("/uploads/",)
class RequestLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
path = request.url.path
# Skip noisy paths
if path in SKIP_PATHS or any(path.startswith(p) for p in SKIP_PREFIXES):
return await call_next(request)
request_id = uuid.uuid4().hex[:8]
request.state.request_id = request_id
# Extract user from JWT (best-effort, never block)
user_id = None
user_email = None
auth = request.headers.get("authorization", "")
if auth.startswith("Bearer "):
try:
from jose import jwt
from app.config import settings
payload = jwt.decode(
auth[7:], settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
options={"verify_exp": False},
)
user_email = payload.get("sub")
except Exception:
pass
start = time.perf_counter()
response = await call_next(request)
duration_ms = round((time.perf_counter() - start) * 1000, 1)
status = response.status_code
method = request.method
client_ip = request.client.host if request.client else "-"
log_data = {
"request_id": request_id,
"method": method,
"path": path,
"status": status,
"duration_ms": duration_ms,
"client_ip": client_ip,
}
if user_email:
log_data["user"] = user_email
if status >= 500:
logger.error("%(method)s %(path)s %(status)s %(duration_ms)sms", log_data, extra=log_data)
elif status >= 400:
logger.warning("%(method)s %(path)s %(status)s %(duration_ms)sms", log_data, extra=log_data)
else:
logger.info("%(method)s %(path)s %(status)s %(duration_ms)sms", log_data, extra=log_data)
return response

View file

@ -1,6 +1,9 @@
import logging
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
logger = logging.getLogger(__name__)
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func from sqlalchemy import func
@ -163,7 +166,7 @@ def submit_attempt(
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
r.delete(f"quiz_progress:{current_user.id}:{attempt.quiz_id}") r.delete(f"quiz_progress:{current_user.id}:{attempt.quiz_id}")
except Exception: except Exception:
pass logger.warning("Failed to clear quiz progress from Redis", exc_info=True)
percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0 percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
@ -173,7 +176,7 @@ def submit_attempt(
from app.services.reminder_service import update_reminder_schedule from app.services.reminder_service import update_reminder_schedule
update_reminder_schedule(db, current_user.id, attempt.quiz_id, percentage) update_reminder_schedule(db, current_user.id, attempt.quiz_id, percentage)
except Exception: except Exception:
pass # Don't fail submission if reminder update fails logger.warning("Failed to update reminder schedule for quiz %d", attempt.quiz_id, exc_info=True)
return AttemptDetail( return AttemptDetail(
id=attempt.id, id=attempt.id,
@ -259,7 +262,7 @@ def save_progress(
"total_time": data.total_time, "total_time": data.total_time,
})) }))
except Exception: except Exception:
pass # Redis unavailable — degrade gracefully logger.warning("Redis unavailable for progress save", exc_info=True)
return {"saved": True} return {"saved": True}
@ -338,11 +341,11 @@ def get_progress(
r.delete(key) r.delete(key)
return None # no progress to resume — already submitted return None # no progress to resume — already submitted
except Exception: except Exception:
pass logger.warning("Failed to check quiz timer expiration", exc_info=True)
return saved return saved
except Exception: except Exception:
pass logger.warning("Redis unavailable for progress retrieval", exc_info=True)
return None return None
@ -358,7 +361,7 @@ def clear_progress(
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
r.delete(f"quiz_progress:{current_user.id}:{attempt_id}") r.delete(f"quiz_progress:{current_user.id}:{attempt_id}")
except Exception: except Exception:
pass logger.warning("Redis unavailable for progress clear", exc_info=True)
@router.delete("/{attempt_id}", status_code=204) @router.delete("/{attempt_id}", status_code=204)

View file

@ -1018,7 +1018,7 @@ async def upload_scorm_package(
launch_file = href launch_file = href
break break
except Exception: except Exception:
pass logger.warning("Failed to parse SCORM manifest for lesson %d", lesson_id, exc_info=True)
if not launch_file: if not launch_file:
launch_file = "index.html" # fallback launch_file = "index.html" # fallback
@ -1072,6 +1072,7 @@ def get_scorm_data(
data = r.get(key) data = r.get(key)
return json.loads(data) if data else {} return json.loads(data) if data else {}
except Exception: except Exception:
logger.warning("Redis unavailable for SCORM data retrieval", exc_info=True)
return {} return {}

View file

@ -1,9 +1,12 @@
"""Question bank — view, search, categorise, and create quizzes from individual questions.""" """Question bank — view, search, categorise, and create quizzes from individual questions."""
import csv import csv
import io import io
import logging
import os import os
import re import re
import uuid import uuid
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
@ -382,7 +385,7 @@ def create_question_manually(
embedding_service.embed_question(question) embedding_service.embed_question(question)
db.commit() db.commit()
except Exception: except Exception:
pass logger.warning("Failed to generate embedding for question %d", question.id, exc_info=True)
return { return {
"id": question.id, "id": question.id,
@ -647,7 +650,7 @@ def upload_questions_csv(
from app.services import embedding_service from app.services import embedding_service
embedding_service.embed_question(q) embedding_service.embed_question(q)
except Exception: except Exception:
pass logger.warning("Failed to generate embedding for imported question %d", q.id, exc_info=True)
if created: if created:
db.commit() db.commit()

View file

@ -1,6 +1,10 @@
from celery import Celery from celery import Celery
from app.config import settings from app.config import settings
from app.logging_config import setup_logging
# Configure structured JSON logging for Celery workers
setup_logging(settings.LOG_LEVEL)
celery_app = Celery( celery_app = Celery(
"quiz_tasks", "quiz_tasks",
@ -11,3 +15,4 @@ celery_app = Celery(
celery_app.conf.task_serializer = "json" celery_app.conf.task_serializer = "json"
celery_app.conf.result_serializer = "json" celery_app.conf.result_serializer = "json"
celery_app.conf.accept_content = ["json"] celery_app.conf.accept_content = ["json"]
celery_app.conf.worker_hijack_root_logger = False # Don't override our JSON logging

View file

@ -24,3 +24,4 @@ openpyxl==3.1.2
authlib==1.3.0 authlib==1.3.0
itsdangerous==2.1.2 itsdangerous==2.1.2
fpdf2==2.8.1 fpdf2==2.8.1
python-json-logger>=2.0.0

View file

@ -33,6 +33,7 @@ services:
- ./backend/.env - ./backend/.env
environment: environment:
- ANONYMIZED_TELEMETRY=False - ANONYMIZED_TELEMETRY=False
- LOG_LEVEL=${LOG_LEVEL:-INFO}
volumes: volumes:
- uploads_data:/app/uploads - uploads_data:/app/uploads
- chroma_data:/app/chroma_data - chroma_data:/app/chroma_data
@ -45,11 +46,12 @@ services:
celery: celery:
build: ./backend build: ./backend
command: celery -A app.tasks worker --loglevel=info --concurrency=2 command: celery -A app.tasks worker --loglevel=${LOG_LEVEL:-info} --concurrency=2
env_file: env_file:
- ./backend/.env - ./backend/.env
environment: environment:
- ANONYMIZED_TELEMETRY=False - ANONYMIZED_TELEMETRY=False
- LOG_LEVEL=${LOG_LEVEL:-INFO}
volumes: volumes:
- uploads_data:/app/uploads - uploads_data:/app/uploads
- chroma_data:/app/chroma_data - chroma_data:/app/chroma_data
@ -66,6 +68,43 @@ services:
- redis_data:/data - redis_data:/data
restart: unless-stopped restart: unless-stopped
# ── Logging: Loki + Promtail + Grafana ──────────────────────────────
loki:
image: grafana/loki:3.3.2
restart: unless-stopped
command: -config.file=/etc/loki/loki-config.yml
volumes:
- ./loki/loki-config.yml:/etc/loki/loki-config.yml:ro
- loki_data:/loki
ports:
- "127.0.0.1:3100:3100"
promtail:
image: grafana/promtail:3.3.2
restart: unless-stopped
command: -config.file=/etc/promtail/promtail-config.yml
volumes:
- ./promtail/promtail-config.yml:/etc/promtail/promtail-config.yml:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- promtail_positions:/positions
depends_on:
- loki
grafana:
image: grafana/grafana:10.3.1
restart: unless-stopped
environment:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD}
GF_AUTH_ANONYMOUS_ENABLED: "false"
volumes:
- grafana_data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
ports:
- "127.0.0.1:3002:3000"
depends_on:
- loki
db-backup: db-backup:
image: prodrigestivill/postgres-backup-local:16 image: prodrigestivill/postgres-backup-local:16
restart: unless-stopped restart: unless-stopped
@ -90,3 +129,6 @@ volumes:
chroma_data: chroma_data:
postgres_data: postgres_data:
redis_data: redis_data:
loki_data:
grafana_data:
promtail_positions:

View file

@ -0,0 +1,8 @@
apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
url: http://loki:3100
isDefault: true
editable: false

36
loki/loki-config.yml Normal file
View file

@ -0,0 +1,36 @@
auth_enabled: false
server:
http_listen_port: 3100
common:
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2020-10-24
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
retention_period: 30d
max_query_series: 500
compactor:
working_directory: /loki/compactor
compaction_interval: 10m
retention_enabled: true
retention_delete_delay: 2h
delete_request_store: filesystem

View file

@ -0,0 +1,48 @@
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /positions/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
# Keep only quiz-* containers
- source_labels: ['__meta_docker_container_name']
regex: '.*quiz.*'
action: keep
# Extract container name as label
- source_labels: ['__meta_docker_container_name']
regex: '/?(.*)'
target_label: container
# Extract compose service name
- source_labels: ['__meta_docker_container_label_com_docker_compose_service']
target_label: service
pipeline_stages:
# Docker JSON log wrapper
- docker: {}
# Try to parse structured JSON logs from backend/celery
- match:
selector: '{service=~"backend|celery"}'
stages:
- json:
expressions:
level: levelname
logger: name
msg: message
request_id: request_id
method: method
path: path
status: status
duration_ms: duration_ms
user: user
- labels:
level:
logger: