feat(R7): metrics + JSON logs + security headers
deps-checksum / checksum lockfile (push) Canceled after 0s
CI / pytest (3.12) (push) Canceled after 0s
CI / Docker smoke test (push) Canceled after 0s

This commit is contained in:
truetype committed 2026-08-31 16:24:51 +02:00
1 parent 12d2018ac5
commit 3681fd95eb
9 files changed
+276 -3

No files matched your search

+9
View File
@@ -1,8 +1,10 @@
import os
import logging
import time
import pypandoc
from config import SUPPORTED_FORMATS
from bookish.services.sse_manager import update_progress, mark_complete, mark_error
from bookish.services.observability import conversion_observe, track_task
logger = logging.getLogger(__name__)
@@ -21,6 +23,9 @@ def run_conversion(input_path, output_path, input_format, output_format, task_id
Esegue la conversione con Pandoc, aggiornando il progresso via SSE.
Per alcuni formati speciali delega a formatter.py.
"""
track_task(1)
started = time.monotonic()
outcome = "completed"
try:
update_progress(task_id, 10, "analyzing")
_validate_pandoc_format(input_format, output_format)
@@ -39,8 +44,12 @@ def run_conversion(input_path, output_path, input_format, output_format, task_id
mark_complete(task_id, os.path.basename(output_path))
except Exception as e:
outcome = "failed"
logger.exception("Conversione fallita: %s -> %s (%s)", input_format, output_format, filename)
mark_error(task_id, str(e))
finally:
conversion_observe(outcome, time.monotonic() - started)
track_task(-1)
def _validate_pandoc_format(input_format, output_format):
+120
View File
@@ -0,0 +1,120 @@
"""
Observability leggera senza dipendenze esterne (R7):
- Contatori Prometheus-style per /metrics (text exposition format).
- Formatter di logging JSON opzionale (LOG_FORMAT=json).
- Watchdog per conversioni lunghe (R5) quando attivo.
Niente export di terze parti: i contatori sono variabili di processo
(single-worker, coerente con il design SSE in-memory).
"""
import json
import logging
import threading
import time
from collections import defaultdict
# ---------------------------------------------------------------------------
# Metrics (Prometheus text exposition)
# ---------------------------------------------------------------------------
_metrics_lock = threading.Lock()
# http: (method, route) -> {"count": int, "sum": float, "count_by_status": {code: int}}
_http = defaultdict(lambda: {"count": 0, "sum": 0.0, "by_status": defaultdict(int)})
# conversioni: result -> {"count": int, "sum_seconds": float}
_conversions = defaultdict(lambda: {"count": 0, "sum_seconds": 0.0})
_started_at = time.time()
_active_tasks = 0
def http_observe(method, route, status_code, seconds):
"""Registra una richiesta HTTP."""
if route is None:
route = "<unknown>"
with _metrics_lock:
m = _http[(method, route)]
m["count"] += 1
m["sum"] += seconds
m["by_status"][status_code] += 1
def conversion_observe(outcome, seconds):
"""outcome: 'completed' | 'failed' | 'timeout'"""
with _metrics_lock:
m = _conversions[outcome]
m["count"] += 1
m["sum_seconds"] += seconds
def track_task(delta):
"""Incrementa/decrementa il gauge dei task attivi."""
global _active_tasks
with _metrics_lock:
_active_tasks = max(0, _active_tasks + delta)
def render_metrics():
"""Endpoint body: testo Prometheus."""
out = []
out.append("# HELP bookish_http_requests_total Richieste HTTP totali per metodo e route.")
out.append("# TYPE bookish_http_requests_total counter")
with _metrics_lock:
for (method, route), m in sorted(_http.items()):
out.append(f'bookish_http_requests_total{{method="{method}",route="{route}"}} {m["count"]}')
for code, c in sorted(m["by_status"].items()):
out.append(f'bookish_http_requests_status{{method="{method}",route="{route}",status="{code}"}} {c}')
out.append(f'bookish_http_request_duration_seconds_sum {sum(m["sum"] for m in _http.values()):.6f}')
out.append(f'bookish_http_request_duration_seconds_count {sum(m["count"] for m in _http.values())}')
for outcome, m in sorted(_conversions.items()):
out.append(f'bookish_conversions_total{{result="{outcome}"}} {m["count"]}')
out.append(f'bookish_conversion_duration_seconds_sum{{result="{outcome}"}} {m["sum_seconds"]:.6f}')
out.append(f'bookish_conversion_duration_seconds_count{{result="{outcome}"}} {m["count"]}')
out.append(f"bookish_active_tasks {_active_tasks}")
out.append(f"bookish_uptime_seconds {time.time() - _started_at:.0f}")
return "\n".join(out) + "\n"
# ---------------------------------------------------------------------------
# Structured logging (LOG_FORMAT=json)
# ---------------------------------------------------------------------------
class JsonFormatter(logging.Formatter):
"""JSON a una riga per riga di log (logstash/graylog friendly)."""
def format(self, record):
payload = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info and record.exc_info[0]:
payload["exc_info"] = self.formatException(record.exc_info)
# extra fields (es. task_id) passano via record.__dict__
for k, v in getattr(record, "__dict__", {}).items():
if k not in payload and not k.startswith("_"):
payload[k] = v
return json.dumps(payload, ensure_ascii=False)
def configure_logging(json_output=None):
"""Configura il root logger: formato JSON se json_output=True."""
if json_output is None:
json_output = False
root = logging.getLogger()
# evita duplicati su reload
for h in list(root.handlers):
root.removeHandler(h)
handler = logging.StreamHandler()
handler.setLevel(logging.INFO)
if json_output:
handler.setFormatter(JsonFormatter())
else:
handler.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)s %(name)s %(message)s"))
root.addHandler(handler)
root.setLevel(logging.INFO)