feat: add request correlation diagnostics
This commit is contained in:
parent
66ebd1a8b8
commit
e2e20fa89f
3
.github/workflows/sonarqube.yml
vendored
3
.github/workflows/sonarqube.yml
vendored
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
4
TODO.md
4
TODO.md
@ -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
|
||||
|
||||
|
||||
49
server.py
49
server.py
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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": {}},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user