feat: add request correlation diagnostics

This commit is contained in:
mARTin-B78 2026-09-04 10:58:46 +02:00
parent 66ebd1a8b8
commit e2e20fa89f
7 changed files with 84 additions and 5 deletions

View File

@ -48,6 +48,9 @@ jobs:
node --check "$f" || exit 1
done
- name: Run frontend collision and manifest checks
run: npm run check:frontend
- name: Build the frontend bundle
run: node scripts/minify.mjs

View File

@ -8,6 +8,10 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Changed
- **CI frontend gate.** The manifest-driven JavaScript syntax and top-level declaration collision check now runs in the
SonarQube quality workflow, alongside Python compilation, regressions, and the production bundle build.
- **Structured request correlation.** API and MCP requests now receive a bounded `X-Request-ID` and emit sanitized
method/path/status/duration logs without recording request bodies or secrets.
- **Audiobook parser extraction.** Dialogue detection, PDF dehyphenation, and shared speaker-heuristic constants now
live in `audiobook-parse.js`, loaded before the Audiobook casting workflow without changing its legacy API.
- **Rehearser voice-picker extraction.** Voice preview state and matched voice-choice presentation now live in

View File

@ -89,6 +89,10 @@ architecture change: the larger Audiobook/Rehearser/style split.
- [ ] **L — Modularize the frontend.** The shared manifest, compatibility namespace
bridge, and section-scoped lazy loading are complete foundations; responsibility-level
extraction and the Audiobook/Rehearser/style split remain.
- [x] **M — Add structured logs with request/job IDs.** API/MCP requests now receive sanitized correlation IDs and
duration logs; route-specific logs retain provider/job context without request bodies.
- [x] **L — Add linting and duplicate-function checks.** The manifest-driven frontend syntax/declaration gate now runs
locally and in the SonarQube quality workflow alongside the existing Python and bundle checks.
### P2 — after behavior and safety stabilize

View File

@ -1 +1 @@
1.24.27
1.24.29

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import os
import time
import uuid
from logging.handlers import RotatingFileHandler
from pathlib import Path
from contextlib import asynccontextmanager
@ -141,6 +142,54 @@ class MutationRateLimitMiddleware:
app.add_middleware(MutationRateLimitMiddleware)
# ── Request/job correlation logging ──────────────────────────────────────────
# Keep request diagnostics useful without recording request bodies. Route-level
# logs add provider/job details where appropriate; this middleware supplies one
# stable correlation ID and timing envelope for every API/MCP request.
class RequestCorrelationMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
if not (path.startswith("/api/") or path == "/mcp"):
await self.app(scope, receive, send)
return
request_id = next((value.decode("latin-1") for key, value in scope.get("headers", [])
if key.lower() == b"x-request-id"), "")
if not request_id or len(request_id) > 80 or any(ord(ch) < 33 or ord(ch) > 126 for ch in request_id):
request_id = uuid.uuid4().hex
started = time.perf_counter()
status = 500
sent = False
async def send_wrapper(message):
nonlocal status, sent
if message["type"] == "http.response.start":
status = int(message.get("status", 500))
headers = MutableHeaders(scope=message)
headers["X-Request-ID"] = request_id
if message["type"] == "http.response.body" and not message.get("more_body", False) and not sent:
sent = True
elapsed_ms = (time.perf_counter() - started) * 1000
logger.info("request id=%s method=%s path=%s status=%s duration_ms=%.1f",
request_id, scope.get("method", ""), path, status, elapsed_ms)
await send(message)
try:
await self.app(scope, receive, send_wrapper)
except Exception:
if not sent:
logger.info("request id=%s method=%s path=%s status=500 duration_ms=%.1f",
request_id, scope.get("method", ""), path,
(time.perf_counter() - started) * 1000)
raise
app.add_middleware(RequestCorrelationMiddleware)
# ── In-memory log buffer handler ──────────────────────────────────────────────
_buf_handler = _BufferHandler()

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.24.27">
<meta name="app-version" content="1.24.29">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.24.27">
<link rel="stylesheet" href="/static/style.css?v=1.24.29">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -392,7 +392,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.24.27"></script>
<script src="/static/loader.js?v=1.24.29"></script>
</body>
</html>

View File

@ -12,10 +12,29 @@ from fastapi import HTTPException
from core.validation import _validate_character_record, _validate_http_url, _validated_upload_suffix
from core.config import _load_settings, _save_settings
from server import RequestSizeLimitMiddleware
from server import RequestCorrelationMiddleware, RequestSizeLimitMiddleware
class SecurityValidationTests(unittest.TestCase):
def test_api_request_correlation_adds_safe_id_header(self):
async def endpoint(scope, receive, send):
await send({"type": "http.response.start", "status": 204, "headers": []})
await send({"type": "http.response.body", "body": b""})
async def run(request_id):
messages = []
scope = {"type": "http", "method": "GET", "path": "/api/diagnostics",
"headers": [(b"x-request-id", request_id.encode())]}
async def send(message): messages.append(message)
await RequestCorrelationMiddleware(endpoint)(scope, lambda: None, send)
return dict(messages[0]["headers"])
headers = asyncio.run(run("test-request-123"))
self.assertEqual(headers[b"x-request-id"], b"test-request-123")
generated = asyncio.run(run("bad\nrequest"))
self.assertRegex(generated[b"x-request-id"].decode(), r"^[0-9a-f]{32}$")
def test_character_record_rejects_missing_id_and_bad_sheet(self):
for record in (
{"name": "Missing id", "sheet": {}},