test_logging.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. """日志格式、关联信息、SSE 请求汇总和异常脱敏。"""
  2. import asyncio
  3. import io
  4. import json
  5. import logging
  6. from pathlib import Path
  7. import pytest
  8. from obs import (setup_logging, get_logger, bind_trace_id, unbind_trace_id, get_trace_id,
  9. get_log_context, log_context, normalize_trace_id)
  10. from obs.log_setup import _NOISY_LOGGERS
  11. from api.request_logging import RequestLogMiddleware
  12. @pytest.fixture
  13. def logs(monkeypatch):
  14. root = logging.getLogger()
  15. previous_handlers, previous_level = root.handlers[:], root.level
  16. names = (*_NOISY_LOGGERS, 'uvicorn', 'uvicorn.error', 'uvicorn.access')
  17. states = {name: (logging.getLogger(name).handlers[:], logging.getLogger(name).level,
  18. logging.getLogger(name).propagate, logging.getLogger(name).disabled) for name in names}
  19. root.handlers = []
  20. stream = io.StringIO()
  21. monkeypatch.setattr('sys.stdout', stream)
  22. def configure(level='INFO', fmt='json'):
  23. monkeypatch.setattr('sys.stdout', stream)
  24. setup_logging(level, fmt)
  25. stream.configure = configure
  26. configure()
  27. try:
  28. yield stream
  29. finally:
  30. for handler in root.handlers:
  31. handler.close()
  32. root.handlers = previous_handlers
  33. root.setLevel(previous_level)
  34. for name, (handlers, level, propagate, disabled) in states.items():
  35. target = logging.getLogger(name)
  36. target.handlers = handlers
  37. target.setLevel(level)
  38. target.propagate = propagate
  39. target.disabled = disabled
  40. def records(logs):
  41. return [json.loads(line) for line in logs.getvalue().splitlines() if line]
  42. def test_json_context_fields_redaction_and_exception(logs):
  43. token = bind_trace_id('request-123')
  44. try:
  45. with log_context(run_id='run-1', session_id='session-1'):
  46. try:
  47. raise RuntimeError('api_key=private-key password=private-password Bearer private-token')
  48. except RuntimeError:
  49. get_logger('test').exception('连接失败 https://user:private-url-password@host/path', extra={'kv': {
  50. 'event': 'test.failed', 'trace_id': 'forged', 'run_id': 'forged', 'message': 'forged',
  51. 'api_key': 'structured-key', 'nested': {'Authorization': 'structured-bearer'}, 'path': Path('/tmp/test'),
  52. }})
  53. finally:
  54. unbind_trace_id(token)
  55. result, = records(logs)
  56. assert result['trace_id'] == 'request-123' and result['run_id'] == 'run-1'
  57. assert result['session_id'] == 'session-1' and result['event'] == 'test.failed'
  58. assert 'Traceback' in result['exc'] and 'RuntimeError' in result['exc']
  59. assert result['api_key'] == '[REDACTED]'
  60. assert result['nested']['Authorization'] == '[REDACTED]'
  61. assert result['path'] == '/tmp/test'
  62. assert all(secret not in logs.getvalue() for secret in (
  63. 'private-key', 'private-password', 'private-token', 'private-url-password', 'structured-key', 'structured-bearer'))
  64. assert get_log_context() == {} and not get_trace_id()
  65. assert result['ts'][-6] in '+-'
  66. def test_text_fields_and_reconfiguration_do_not_duplicate(logs):
  67. foreign = logging.NullHandler()
  68. logging.getLogger().addHandler(foreign)
  69. logs.configure('INFO', 'text')
  70. logs.configure('INFO', 'text')
  71. assert foreign in logging.getLogger().handlers
  72. assert sum(bool(getattr(h, '_vent_handler', False)) for h in logging.getLogger().handlers) == 1
  73. get_logger('uvicorn.error').info('服务器就绪', extra={'kv': {'event': 'server.ready', 'duration_ms': 12}})
  74. get_logger('uvicorn.access').info('duplicate request')
  75. get_logger('runtime.concurrency').debug('并发细节')
  76. assert logs.getvalue().count('服务器就绪') == 1
  77. assert 'event="server.ready"' in logs.getvalue() and 'duration_ms=12' in logs.getvalue()
  78. assert 'duplicate request' not in logs.getvalue() and '并发细节' not in logs.getvalue()
  79. @pytest.mark.parametrize('value', ['line\nbreak', 'a' * 129, '中文', '', None])
  80. def test_invalid_trace_is_replaced(value):
  81. result = normalize_trace_id(value)
  82. assert len(result) == 32 and all(c in '0123456789abcdef' for c in result)
  83. @pytest.mark.asyncio
  84. async def test_context_isolated_between_tasks_and_propagates_to_threads(logs):
  85. async def run(name):
  86. token = bind_trace_id(name)
  87. try:
  88. with log_context(session_id=name):
  89. await asyncio.sleep(0)
  90. await asyncio.to_thread(get_logger('thread').info, '线程日志')
  91. finally:
  92. unbind_trace_id(token)
  93. await asyncio.gather(run('one'), run('two'))
  94. assert {(r['trace_id'], r['session_id']) for r in records(logs)} == {('one', 'one'), ('two', 'two')}
  95. assert not get_trace_id() and not get_log_context()
  96. def scope(path='/api/chat'):
  97. return {'type': 'http', 'method': 'POST', 'path': path, 'headers': [(b'x-trace-id', b'http-test')],
  98. 'query_string': b'api_key=never-log-query'}
  99. @pytest.mark.asyncio
  100. async def test_sse_request_logged_only_after_stream_and_cleanup(logs):
  101. first, finish = asyncio.Event(), asyncio.Event()
  102. sent = []
  103. async def app(scope, receive, send):
  104. await send({'type': 'http.response.start', 'status': 200, 'headers': []})
  105. await send({'type': 'http.response.body', 'body': b'data: secret-body\n\n', 'more_body': True})
  106. first.set()
  107. await finish.wait()
  108. await send({'type': 'http.response.body', 'body': b'', 'more_body': False})
  109. get_logger('cleanup').info('清理完成')
  110. async def receive():
  111. return {'type': 'http.request', 'body': b'never-log-request'}
  112. async def send(message):
  113. sent.append(message)
  114. task = asyncio.create_task(RequestLogMiddleware(app)(scope(), receive, send))
  115. await first.wait()
  116. assert records(logs) == []
  117. finish.set()
  118. await task
  119. result = records(logs)
  120. assert result[-1]['event'] == 'http.request.completed'
  121. assert result[-1]['outcome'] == 'completed' and result[-1]['status_code'] == 200
  122. assert result[-1]['duration_ms'] >= 0
  123. assert all(r['trace_id'] == 'http-test' for r in result)
  124. assert (b'x-trace-id', b'http-test') in sent[0]['headers']
  125. assert 'never-log' not in logs.getvalue() and 'secret-body' not in logs.getvalue()
  126. @pytest.mark.asyncio
  127. @pytest.mark.parametrize('mode,status,outcome', [('error', 500, 'error'), ('cancel', None, 'cancelled'), ('disconnect', None, 'disconnected')])
  128. async def test_request_failures_logged_once_and_restore_context(logs, mode, status, outcome):
  129. async def app(scope, receive, send):
  130. if mode == 'error':
  131. raise ValueError('failed')
  132. if mode == 'cancel':
  133. raise asyncio.CancelledError
  134. await receive()
  135. async def receive():
  136. return {'type': 'http.disconnect'}
  137. async def send(message):
  138. pass
  139. request = RequestLogMiddleware(app)(scope(), receive, send)
  140. if mode in {'error', 'cancel'}:
  141. with pytest.raises(ValueError if mode == 'error' else asyncio.CancelledError):
  142. await request
  143. else:
  144. await request
  145. row, = records(logs)
  146. assert row['status_code'] == status and row['outcome'] == outcome
  147. assert not get_trace_id()
  148. @pytest.mark.asyncio
  149. async def test_health_noise_is_debug_but_errors_are_visible(logs):
  150. logs.configure('INFO', 'json')
  151. status = 200
  152. async def app(scope, receive, send):
  153. await send({'type': 'http.response.start', 'status': status})
  154. await send({'type': 'http.response.body'})
  155. async def noop(*args):
  156. pass
  157. await RequestLogMiddleware(app)(scope('/health'), noop, noop)
  158. assert records(logs) == []
  159. status = 503
  160. await RequestLogMiddleware(app)(scope('/health'), noop, noop)
  161. assert records(logs)[0]['level'] == 'ERROR'
  162. @pytest.mark.asyncio
  163. async def test_batch_logs_events_and_billing_share_trace(logs, monkeypatch, tmp_path, pricing_ready):
  164. import config
  165. from db import chat_store, billing_store, db_conn
  166. from runtime.execution import execute
  167. from runtime import concurrency
  168. from test_execution import Agent
  169. monkeypatch.setattr(config, 'ROOT', tmp_path)
  170. concurrency.reset_for_tests()
  171. sessions = [chat_store.create_session('log-user')['id'] for _ in range(2)]
  172. await asyncio.gather(*(execute(user_name='log-user', session_id=sid, message='never-log-prompt', agent=Agent()) for sid in sessions))
  173. completed = [r for r in records(logs) if r.get('event') == 'execution.completed']
  174. assert len(completed) == 2
  175. assert {r['session_id'] for r in completed} == set(sessions)
  176. assert len({r['trace_id'] for r in completed}) == 2
  177. for row in completed:
  178. assert row['status'] == 'success' and row['input_tokens'] == 8
  179. usage = db_conn.query_one('SELECT trace_id FROM billing_usage WHERE id = ?', (row['run_id'],))
  180. assert usage['trace_id'] == row['trace_id']
  181. start = next(r for r in records(logs) if r.get('event') == 'execution.started' and r['run_id'] == row['run_id'])
  182. assert start['trace_id'] == row['trace_id']
  183. assert 'never-log-prompt' not in logs.getvalue()
  184. assert not get_trace_id() and not get_log_context()
  185. @pytest.mark.asyncio
  186. async def test_real_http_response_header_and_access_log_match(logs, monkeypatch):
  187. import httpx
  188. from main import app
  189. # main 导入可能重新配置日志,将本例格式明确设回 JSON。
  190. logs.configure('INFO', 'json')
  191. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
  192. response = await client.get('/api/no-such-route', headers={'X-Trace-Id': 'invalid trace'})
  193. rows = [r for r in records(logs) if r.get('event') == 'http.request.completed']
  194. assert len(rows) == 1 and rows[0]['status_code'] == 404
  195. assert rows[0]['trace_id'] == response.headers['x-trace-id']
  196. assert len(rows[0]['trace_id']) == 32
  197. @pytest.mark.asyncio
  198. async def test_http_unhandled_exception_keeps_trace_and_stack(logs):
  199. import httpx
  200. from fastapi import FastAPI
  201. from main import unhandled_exception_handler
  202. app = FastAPI()
  203. app.add_middleware(RequestLogMiddleware)
  204. app.add_exception_handler(Exception, unhandled_exception_handler)
  205. @app.get('/broken')
  206. async def broken():
  207. raise RuntimeError('password=hidden-error-password')
  208. logs.configure()
  209. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, raise_app_exceptions=False), base_url='http://test') as client:
  210. response = await client.get('/broken', headers={'X-Trace-Id': 'broken-request'})
  211. assert response.status_code == 500
  212. assert response.headers['x-trace-id'] == 'broken-request'
  213. related = [r for r in records(logs) if r.get('event', '').startswith('http.request.')]
  214. assert len(related) == 2 # 一条请求汇总、一条错误堆栈。
  215. assert all(r['trace_id'] == 'broken-request' for r in related)
  216. assert 'Traceback' in next(r for r in related if r['event'] == 'http.request.failed')['exc']
  217. assert 'hidden-error-password' not in logs.getvalue()
  218. @pytest.mark.asyncio
  219. async def test_execution_event_trace_matches_logs_when_not_provided(logs, monkeypatch, tmp_path, pricing_ready):
  220. import config
  221. from db import chat_store
  222. from runtime.execution import admit, build_execution
  223. from test_execution import Agent
  224. monkeypatch.setattr(config, 'ROOT', tmp_path)
  225. sid = chat_store.create_session('event-user')['id']
  226. admission = await admit('event-user', sid)
  227. execution = await build_execution(admission, agent=Agent(), session_id=sid, thread_id=sid,
  228. user_name='event-user', message='问题', config={'configurable': {'thread_id': sid}})
  229. events = [event async for event in execution.events()]
  230. assert events and all(event['trace_id'] == execution.req.trace_id for event in events)
  231. completed = [r for r in records(logs) if r.get('event') == 'execution.completed']
  232. assert len(completed) == 1 and completed[0]['trace_id'] == execution.req.trace_id
  233. assert not get_trace_id() and not get_log_context()
  234. @pytest.mark.asyncio
  235. async def test_schedule_and_execution_share_trace(logs, monkeypatch, tmp_path, pricing_ready):
  236. import config
  237. from db import schedule_store
  238. from scheduler.runner import run_schedule
  239. from test_execution import Agent
  240. monkeypatch.setattr(config, 'ROOT', tmp_path)
  241. monkeypatch.setattr('agents.agent_registry.get_agent', lambda: Agent())
  242. schedule = schedule_store.create_schedule('log-user', 'report', '0 9 * * *', 'never-log-schedule-prompt')
  243. result = await run_schedule(schedule['id'])
  244. assert result['ok']
  245. run = next(r for r in records(logs) if r.get('event') == 'execution.completed')
  246. scheduled = next(r for r in records(logs) if r.get('event') == 'schedule.completed')
  247. assert run['trace_id'] == scheduled['trace_id'] != '-'
  248. assert run['schedule_id'] == scheduled['schedule_id'] == schedule['id']
  249. assert run['session_id'] == scheduled['session_id'] == result['session_id']
  250. assert scheduled['schedule_run_id'] == result['run_id']
  251. assert 'never-log-schedule-prompt' not in logs.getvalue()
  252. assert not get_trace_id() and not get_log_context()