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
|
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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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
|
- [ ] **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
|
||||||
|
|
||||||
|
|||||||
49
server.py
49
server.py
@ -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()
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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": {}},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user