reporting.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. import asyncio
  2. import json
  3. import time
  4. import traceback
  5. from typing import Any
  6. from fastapi import Request
  7. from starlette.responses import Response
  8. from config import settings
  9. MAX_CLOUD_LOG_VALUE_LENGTH = 64 * 1024
  10. MAX_LOG_BATCH_SIZE = 50
  11. MAX_LOG_RETRIES = 3
  12. SENSITIVE_FIELD_PARTS = ('authorization', 'api_key', 'apikey', 'token', 'password', 'secret')
  13. def set_failure(request: Request, stage: str, exc: Exception, message: str | None = None) -> None:
  14. request.state.failure_stage = stage
  15. request.state.error_type = type(exc).__name__
  16. request.state.error_reason = message if message is not None else str(exc)
  17. request.state.error_traceback = traceback.format_exc()
  18. def format_validation_errors(errors: list[dict]) -> str:
  19. """把FastAPI/Pydantic错误转换为稳定且可定位的中文信息。"""
  20. messages = []
  21. for error in errors:
  22. location_items = list(error.get('loc', ()))
  23. if location_items and location_items[0] == 'body':
  24. location_items.pop(0)
  25. location = '.'.join(str(item) for item in location_items) or 'body'
  26. error_type = error.get('type', '')
  27. input_value = str(error.get('input', ''))[:100]
  28. context = error.get('ctx') or {}
  29. if error_type == 'extra_forbidden':
  30. message = f'不支持的参数: {location}'
  31. elif error_type == 'literal_error':
  32. message = f'{location}不支持值 {input_value},允许值为{context.get("expected", "白名单值")}'
  33. elif error_type == 'missing':
  34. message = f'{location}不能为空'
  35. elif error_type == 'list_too_long':
  36. message = f'{location}最多允许{context.get("max_length")}项'
  37. elif error_type == 'list_too_short':
  38. message = f'{location}至少需要{context.get("min_length")}项'
  39. elif error_type == 'less_than_equal':
  40. message = f'{location}不能大于{context.get("le")}'
  41. elif error_type == 'greater_than_equal':
  42. message = f'{location}不能小于{context.get("ge")}'
  43. elif error_type == 'value_error':
  44. detail = error.get('msg', '').removeprefix('Value error, ')
  45. message = f'{location}: {detail}'
  46. else:
  47. message = f'{location}: {error.get("msg", "参数格式错误")}'
  48. messages.append(message)
  49. return '参数校验失败: ' + '; '.join(messages)
  50. def _redact_value(value: Any):
  51. if isinstance(value, dict):
  52. return {
  53. key: '***' if any(part in str(key).lower() for part in SENSITIVE_FIELD_PARTS)
  54. else _redact_value(item)
  55. for key, item in value.items()
  56. }
  57. if isinstance(value, list):
  58. return [_redact_value(item) for item in value]
  59. return value
  60. def _redact_text(value: str) -> str:
  61. result = value
  62. for secret in (
  63. settings.DB_PASSWORD,
  64. settings.ALIYUN_ACCESS_KEY_ID,
  65. settings.ALIYUN_ACCESS_KEY_SECRET,
  66. ):
  67. if secret:
  68. result = result.replace(str(secret), '***')
  69. return result
  70. def _limit_log_value(value):
  71. value = _redact_value(value)
  72. text = json.dumps(value, ensure_ascii=False, default=str)
  73. if len(text) <= MAX_CLOUD_LOG_VALUE_LENGTH:
  74. return value
  75. return {
  76. 'truncated': True,
  77. 'original_length': len(text),
  78. 'content': text[:MAX_CLOUD_LOG_VALUE_LENGTH],
  79. }
  80. def _request_url(request: Request) -> str:
  81. scheme = request.headers.get('X-Forwarded-Proto', request.url.scheme).split(',', 1)[0].strip()
  82. host = request.headers.get('X-Forwarded-Host', request.headers.get('host', '')).split(',', 1)[0].strip()
  83. return f'{scheme}://{host}{request.url.path}' + (f'?{request.url.query}' if request.url.query else '')
  84. async def _send_cloud_log_batch(app, events: list[dict]) -> None:
  85. await asyncio.wait_for(
  86. asyncio.to_thread(app.state.aliyun_logger.logging_batch, events),
  87. timeout=settings.API_LOG_FLUSH_TIMEOUT,
  88. )
  89. async def cloud_log_worker(app) -> None:
  90. """异步批量上报访问日志,不阻塞业务请求。"""
  91. queue = app.state.cloud_log_queue
  92. logger = app.state.logger
  93. while True:
  94. first_event = await queue.get()
  95. if first_event is None:
  96. queue.task_done()
  97. return
  98. events = [first_event]
  99. while len(events) < MAX_LOG_BATCH_SIZE:
  100. try:
  101. event = queue.get_nowait()
  102. except asyncio.QueueEmpty:
  103. break
  104. if event is None:
  105. queue.task_done()
  106. break
  107. events.append(event)
  108. try:
  109. for attempt in range(MAX_LOG_RETRIES):
  110. try:
  111. await _send_cloud_log_batch(app, events)
  112. break
  113. except Exception as exc:
  114. if attempt + 1 >= MAX_LOG_RETRIES:
  115. logger.exception(
  116. f'阿里云API日志批量上报失败: count={len(events)}, '
  117. f'destination={settings.API_ALIYUN_LOG_PROJECT}/'
  118. f'{settings.API_ALIYUN_LOGSTORE}, '
  119. f'error={type(exc).__name__}: {exc}'
  120. )
  121. else:
  122. await asyncio.sleep(0.5 * (2 ** attempt))
  123. finally:
  124. for _ in events:
  125. queue.task_done()
  126. async def _enqueue_cloud_log(app, event: dict) -> None:
  127. queue = getattr(app.state, 'cloud_log_queue', None)
  128. if queue is None:
  129. try:
  130. await _send_cloud_log_batch(app, [event])
  131. except Exception as exc:
  132. app.state.logger.exception(
  133. f'阿里云API日志上报失败: '
  134. f'destination={settings.API_ALIYUN_LOG_PROJECT}/'
  135. f'{settings.API_ALIYUN_LOGSTORE}, '
  136. f'error={type(exc).__name__}: {exc}'
  137. )
  138. return
  139. try:
  140. queue.put_nowait(event)
  141. except asyncio.QueueFull:
  142. app.state.logger.error(
  143. f'阿里云API日志队列已满,丢弃日志: request_id={event.get("trace_id", "")}'
  144. )
  145. async def report_api_request(request: Request, response: Response) -> None:
  146. """统一记录本地访问日志,并异步上报阿里云日志。"""
  147. logger = request.app.state.logger
  148. duration_ms = round((time.perf_counter() - request.state.started_at) * 1000, 2)
  149. request_params = getattr(request.state, 'request_params', {'query': {}, 'body': None})
  150. safe_request_params = _limit_log_value(request_params)
  151. error_message = _redact_text(
  152. getattr(request.state, 'error_traceback', '')
  153. or getattr(request.state, 'error_reason', '')
  154. )
  155. request_url = _request_url(request)
  156. local_message = (
  157. f'API请求 request_id={request.state.request_id} method={request.method} url={request_url} '
  158. f'params={json.dumps(safe_request_params, ensure_ascii=False, default=str)} '
  159. f'status={response.status_code} duration_ms={duration_ms}'
  160. )
  161. if error_message:
  162. logger.error(f'{local_message} error={error_message}')
  163. else:
  164. logger.info(local_message)
  165. request_body = request_params.get('body') if isinstance(request_params, dict) else None
  166. request_body = request_body if isinstance(request_body, dict) else {}
  167. query_metrics = getattr(request.state, 'query_metrics', {})
  168. cloud_data = {
  169. 'url': request_url,
  170. 'path': request.url.path,
  171. 'method': request.method,
  172. 'request_id': request.state.request_id,
  173. 'request_params': _limit_log_value(request_body),
  174. 'platforms': request_body.get('platforms'),
  175. 'status_code': response.status_code,
  176. 'success': response.status_code < 400,
  177. 'request_duration_ms': duration_ms,
  178. 'failure_stage': getattr(request.state, 'failure_stage', ''),
  179. 'error_type': getattr(request.state, 'error_type', ''),
  180. 'message': error_message,
  181. 'query_result_count': query_metrics.get('result_count'),
  182. 'query_duration_ms': query_metrics.get('duration_ms'),
  183. 'query_success': query_metrics.get('success'),
  184. }
  185. await _enqueue_cloud_log(request.app, {
  186. 'code': '2000' if response.status_code < 400 else '9000',
  187. 'message': 'API请求成功' if response.status_code < 400 else error_message,
  188. 'data': cloud_data,
  189. 'trace_id': request.state.request_id,
  190. })