http_logging.py 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. """ASGI response logging outside authentication, without consuming request bodies."""
  2. import logging
  3. import time
  4. from uuid import uuid4
  5. from starlette.responses import PlainTextResponse
  6. from .logging_setup import request_context
  7. logger = logging.getLogger(__name__)
  8. class RequestLoggingMiddleware:
  9. def __init__(self, app):
  10. self.app = app
  11. async def __call__(self, scope, receive, send):
  12. if scope["type"] != "http":
  13. return await self.app(scope, receive, send)
  14. request_id = uuid4().hex
  15. scope.setdefault("state", {})["request_id"] = request_id
  16. token = request_context.set({"request_id": request_id, "scope": scope})
  17. started = time.perf_counter()
  18. status, completed, failed = None, False, False
  19. def finish(outcome, level=None):
  20. nonlocal completed
  21. if completed:
  22. return
  23. completed = True
  24. level = level or (
  25. logging.ERROR
  26. if status and status >= 500
  27. else (
  28. logging.WARNING
  29. if status and status >= 400
  30. else (
  31. logging.DEBUG
  32. if scope["path"] == "/health" or scope["path"].startswith("/static/")
  33. else logging.INFO
  34. )
  35. )
  36. )
  37. logger.log(
  38. level,
  39. "HTTP-Anfrage abgeschlossen",
  40. extra={
  41. "event": "http_completed",
  42. "status": status,
  43. "duration_ms": round((time.perf_counter() - started) * 1000, 2),
  44. "outcome": outcome,
  45. },
  46. )
  47. async def logged_send(message):
  48. nonlocal status
  49. if message["type"] == "http.response.start":
  50. message = dict(
  51. message,
  52. headers=[
  53. (key, value) for key, value in message.get("headers", []) if key.lower() != b"x-request-id"
  54. ]
  55. + [(b"x-request-id", request_id.encode("ascii"))],
  56. )
  57. await send(message)
  58. if message["type"] == "http.response.start":
  59. status = message["status"]
  60. if message["type"] == "http.response.body" and not message.get("more_body", False):
  61. finish("completed")
  62. try:
  63. await self.app(scope, receive, logged_send)
  64. except Exception:
  65. failed = True
  66. streaming = status is not None
  67. logger.exception(
  68. "HTTP-Verarbeitung fehlgeschlagen",
  69. extra={"event": "response_stream_error" if streaming else "unhandled_exception"},
  70. )
  71. if status is None:
  72. status = 500
  73. if not completed:
  74. finish("stream_failed" if streaming else "failed", logging.ERROR)
  75. raise
  76. finally:
  77. if not completed and not failed:
  78. finish("aborted", logging.WARNING)
  79. request_context.reset(token)
  80. async def unhandled_error_response(request, exc):
  81. request_id = getattr(request.state, "request_id", None)
  82. return PlainTextResponse(
  83. "Internal Server Error", status_code=500, headers={"X-Request-ID": request_id} if request_id else {}
  84. )
  85. def install_request_logging(app):
  86. app.add_middleware(RequestLoggingMiddleware)
  87. app.add_exception_handler(Exception, unhandled_error_response)