request_logging.py 3.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
  1. """ASGI 请求日志:覆盖完整 SSE 生命周期,不读取请求正文或查询参数。"""
  2. import asyncio
  3. import logging
  4. import time
  5. from obs import bind_trace_id, unbind_trace_id, normalize_trace_id, get_logger
  6. logger = get_logger('api.access')
  7. class RequestLogMiddleware:
  8. def __init__(self, app, *, trace_enabled=True):
  9. self.app = app
  10. self.trace_enabled = trace_enabled
  11. async def __call__(self, scope, receive, send):
  12. if scope['type'] != 'http':
  13. return await self.app(scope, receive, send)
  14. started = time.perf_counter()
  15. headers = dict(scope.get('headers', []))
  16. trace_id = normalize_trace_id(headers.get(b'x-trace-id', b'').decode('latin1'))
  17. token = bind_trace_id(trace_id) if self.trace_enabled else None
  18. if self.trace_enabled:
  19. scope.setdefault('state', {})['trace_id'] = trace_id
  20. status, completed, disconnected, failure = None, False, False, None
  21. async def logged_receive():
  22. nonlocal disconnected
  23. message = await receive()
  24. if message['type'] == 'http.disconnect':
  25. disconnected = True
  26. return message
  27. async def logged_send(message):
  28. nonlocal status, completed
  29. if message['type'] == 'http.response.start':
  30. status = message['status']
  31. if self.trace_enabled:
  32. message = {**message, 'headers': [(k, v) for k, v in message.get('headers', [])
  33. if k.lower() != b'x-trace-id']
  34. + [(b'x-trace-id', trace_id.encode('ascii'))]}
  35. await send(message)
  36. if message['type'] == 'http.response.body' and not message.get('more_body', False):
  37. completed = True
  38. try:
  39. await self.app(scope, logged_receive, logged_send)
  40. except BaseException as exc:
  41. failure = exc
  42. raise
  43. finally:
  44. try:
  45. outcome = ('cancelled' if isinstance(failure, asyncio.CancelledError)
  46. else 'error' if failure else 'completed' if completed
  47. else 'disconnected' if disconnected else 'incomplete')
  48. code = status if status is not None else (500 if failure and outcome == 'error' else None)
  49. level = logging.ERROR if outcome == 'error' or (code and code >= 500) else (
  50. logging.WARNING if outcome != 'completed' or (code and code >= 400) else logging.INFO)
  51. path = scope.get('path', '')
  52. if level == logging.INFO and (path == '/health' or path.startswith('/static/') or path == '/favicon.ico'):
  53. level = logging.DEBUG
  54. route = scope.get('route')
  55. logger.log(level, '请求结束', extra={'kv': {
  56. 'event': 'http.request.completed', 'method': scope.get('method'),
  57. 'path': getattr(route, 'path', path), 'status_code': code, 'outcome': outcome,
  58. 'duration_ms': round((time.perf_counter() - started) * 1000, 1),
  59. **({'error_type': type(failure).__name__} if failure else {}),
  60. }})
  61. finally:
  62. if token is not None:
  63. unbind_trace_id(token)