log_setup.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. """统一业务与服务器日志:文本便于阅读,JSON 便于采集。"""
  2. from __future__ import annotations
  3. import json
  4. import logging
  5. from logging.handlers import RotatingFileHandler
  6. from pathlib import Path
  7. import re
  8. import sys
  9. from datetime import datetime
  10. from obs.tracing import get_trace_id, get_log_context
  11. _NOISY_LOGGERS = ('httpx', 'httpcore', 'urllib3', 'openai', 'aiosqlite',
  12. 'apscheduler.executors.default', 'apscheduler.scheduler',
  13. 'langchain_openai.chat_models._client_utils')
  14. _RESERVED = {'ts', 'level', 'logger', 'trace_id', 'message', 'exc'}
  15. _SECRET_KEYS = {'authorization', 'proxy_authorization', 'api_key', 'openai_api_key',
  16. 'password', 'secret', 'token', 'access_token', 'refresh_token', 'cookie', 'set_cookie'}
  17. _ASSIGNMENT = re.compile(r'(?i)((?:api[_-]?key|password|access[_-]?token|refresh[_-]?token|secret)\s*[=:]\s*[\"\']?)[^\s,;\"\'&]+')
  18. _BEARER = re.compile(r'(?i)(bearer\s+)[A-Za-z0-9._~+/-]+=*')
  19. _URL_CREDENTIALS = re.compile(r'(://)[^\s/@]+:[^\s/@]+@')
  20. def _safe(value):
  21. if isinstance(value, dict):
  22. return {str(k): '[REDACTED]' if str(k).lower().replace('-', '_') in _SECRET_KEYS else _safe(v)
  23. for k, v in value.items()}
  24. if isinstance(value, (list, tuple)):
  25. return [_safe(item) for item in value]
  26. if isinstance(value, str):
  27. value = _ASSIGNMENT.sub(r'\1[REDACTED]', value)
  28. value = _BEARER.sub(r'\1[REDACTED]', value)
  29. return _URL_CREDENTIALS.sub(r'\1[REDACTED]@', value)
  30. return value
  31. class _TraceIdFilter(logging.Filter):
  32. def filter(self, record):
  33. record.trace_id = get_trace_id() or '-'
  34. record.log_context = get_log_context()
  35. return True
  36. def _payload(record):
  37. payload = {
  38. 'ts': datetime.fromtimestamp(record.created).astimezone().isoformat(timespec='milliseconds'),
  39. 'level': record.levelname,
  40. 'logger': record.name,
  41. 'trace_id': getattr(record, 'trace_id', '-'),
  42. 'message': _safe(record.getMessage()),
  43. }
  44. context = getattr(record, 'log_context', {})
  45. kv = getattr(record, 'kv', {})
  46. if isinstance(kv, dict):
  47. payload.update({k: v for k, v in _safe(kv).items() if k not in _RESERVED})
  48. # 绑定的执行身份优先于临时字段,防止覆盖关联信息。
  49. payload.update({k: v for k, v in _safe(context).items() if k not in _RESERVED})
  50. if record.exc_info:
  51. payload['exc'] = _safe(logging.Formatter().formatException(record.exc_info))
  52. if record.stack_info:
  53. payload['stack'] = _safe(record.stack_info)
  54. return payload
  55. class _JsonFormatter(logging.Formatter):
  56. def format(self, record):
  57. return json.dumps(_payload(record), ensure_ascii=False, default=str)
  58. class _TextFormatter(logging.Formatter):
  59. def format(self, record):
  60. p = _payload(record)
  61. # JSON 转义保持一条普通日志一行;异常堆栈在下一行完整呈现。
  62. message = str(p['message']).replace('\r', r'\r').replace('\n', r'\n')
  63. fields = ' '.join(f'{key}={json.dumps(value, ensure_ascii=False, default=str)}'
  64. for key, value in p.items() if key not in _RESERVED)
  65. line = f"{p['ts']} {p['level']:<7} [{p['trace_id']}] {p['logger']} | {message}"
  66. if fields:
  67. line += ' | ' + fields
  68. if p.get('exc'):
  69. line += '\n' + p['exc']
  70. return line
  71. def setup_logging(level: str = 'INFO', fmt: str = 'text') -> None:
  72. """重复配置只替换自身 handler,保留测试/宿主添加的采集器。"""
  73. root = logging.getLogger()
  74. root.setLevel(getattr(logging, str(level).upper(), logging.INFO))
  75. for handler in list(root.handlers):
  76. if getattr(handler, '_vent_handler', False):
  77. root.removeHandler(handler)
  78. handler.close()
  79. handler = logging.StreamHandler(sys.stdout)
  80. handler._vent_handler = True
  81. handler.addFilter(_TraceIdFilter())
  82. handler.setFormatter(_JsonFormatter() if str(fmt).lower() == 'json' else _TextFormatter())
  83. root.addHandler(handler)
  84. import config
  85. destination = config.get_str('LOG_FILE')
  86. if destination:
  87. path = Path(destination)
  88. if not path.is_absolute():
  89. path = config.ROOT / path
  90. path.parent.mkdir(parents=True, exist_ok=True)
  91. file_handler = RotatingFileHandler(path, encoding='utf-8',
  92. maxBytes=max(1, config.get_int('LOG_MAX_MB', 20)) * 1024 * 1024,
  93. backupCount=max(1, config.get_int('LOG_BACKUP_COUNT', 5)))
  94. file_handler._vent_handler = True
  95. file_handler.addFilter(_TraceIdFilter())
  96. file_handler.setFormatter(_JsonFormatter() if str(fmt).lower() == 'json' else _TextFormatter())
  97. root.addHandler(file_handler)
  98. for name in _NOISY_LOGGERS:
  99. logging.getLogger(name).setLevel(logging.WARNING)
  100. for name in ('uvicorn', 'uvicorn.error', 'uvicorn.access'):
  101. server = logging.getLogger(name)
  102. server.handlers.clear()
  103. server.propagate = True
  104. server.setLevel(logging.NOTSET)
  105. # 请求汇总由 ASGI 中间件记录,包含流结束时间,避免 Uvicorn 再记一遍。
  106. logging.getLogger('uvicorn.access').disabled = True
  107. def get_logger(name: str) -> logging.Logger:
  108. return logging.getLogger(name)