log_setup.py 4.4 KB

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