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 node --check "$f" || exit 1
done done
- name: Run frontend collision and manifest checks
run: npm run check:frontend
- name: Build the frontend bundle - name: Build the frontend bundle
run: node scripts/minify.mjs 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] ## [Unreleased]
### Changed ### 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 - **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. 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 - **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 - [ ] **L — Modularize the frontend.** The shared manifest, compatibility namespace
bridge, and section-scoped lazy loading are complete foundations; responsibility-level bridge, and section-scoped lazy loading are complete foundations; responsibility-level
extraction and the Audiobook/Rehearser/style split remain. 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 ### 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 logging
import os import os
import time import time
import uuid
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from pathlib import Path from pathlib import Path
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -141,6 +142,54 @@ class MutationRateLimitMiddleware:
app.add_middleware(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 ────────────────────────────────────────────── # ── In-memory log buffer handler ──────────────────────────────────────────────
_buf_handler = _BufferHandler() _buf_handler = _BufferHandler()

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <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="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <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) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -392,7 +392,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- 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> </body>
</html> </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.validation import _validate_character_record, _validate_http_url, _validated_upload_suffix
from core.config import _load_settings, _save_settings from core.config import _load_settings, _save_settings
from server import RequestSizeLimitMiddleware from server import RequestCorrelationMiddleware, RequestSizeLimitMiddleware
class SecurityValidationTests(unittest.TestCase): 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): def test_character_record_rejects_missing_id_and_bad_sheet(self):
for record in ( for record in (
{"name": "Missing id", "sheet": {}}, {"name": "Missing id", "sheet": {}},