"""日志格式、关联信息、SSE 请求汇总和异常脱敏。""" import asyncio import io import json import logging from pathlib import Path import pytest from obs import (setup_logging, get_logger, bind_trace_id, unbind_trace_id, get_trace_id, get_log_context, log_context, normalize_trace_id) from obs.log_setup import _NOISY_LOGGERS from api.request_logging import RequestLogMiddleware @pytest.fixture def logs(monkeypatch): root = logging.getLogger() previous_handlers, previous_level = root.handlers[:], root.level names = (*_NOISY_LOGGERS, 'uvicorn', 'uvicorn.error', 'uvicorn.access') states = {name: (logging.getLogger(name).handlers[:], logging.getLogger(name).level, logging.getLogger(name).propagate, logging.getLogger(name).disabled) for name in names} root.handlers = [] stream = io.StringIO() monkeypatch.setattr('sys.stdout', stream) def configure(level='INFO', fmt='json'): monkeypatch.setattr('sys.stdout', stream) setup_logging(level, fmt) stream.configure = configure configure() try: yield stream finally: for handler in root.handlers: handler.close() root.handlers = previous_handlers root.setLevel(previous_level) for name, (handlers, level, propagate, disabled) in states.items(): target = logging.getLogger(name) target.handlers = handlers target.setLevel(level) target.propagate = propagate target.disabled = disabled def records(logs): return [json.loads(line) for line in logs.getvalue().splitlines() if line] def test_json_context_fields_redaction_and_exception(logs): token = bind_trace_id('request-123') try: with log_context(run_id='run-1', session_id='session-1'): try: raise RuntimeError('api_key=private-key password=private-password Bearer private-token') except RuntimeError: get_logger('test').exception('连接失败 https://user:private-url-password@host/path', extra={'kv': { 'event': 'test.failed', 'trace_id': 'forged', 'run_id': 'forged', 'message': 'forged', 'api_key': 'structured-key', 'nested': {'Authorization': 'structured-bearer'}, 'path': Path('/tmp/test'), }}) finally: unbind_trace_id(token) result, = records(logs) assert result['trace_id'] == 'request-123' and result['run_id'] == 'run-1' assert result['session_id'] == 'session-1' and result['event'] == 'test.failed' assert 'Traceback' in result['exc'] and 'RuntimeError' in result['exc'] assert result['api_key'] == '[REDACTED]' assert result['nested']['Authorization'] == '[REDACTED]' assert result['path'] == '/tmp/test' assert all(secret not in logs.getvalue() for secret in ( 'private-key', 'private-password', 'private-token', 'private-url-password', 'structured-key', 'structured-bearer')) assert get_log_context() == {} and not get_trace_id() assert result['ts'][-6] in '+-' def test_text_fields_and_reconfiguration_do_not_duplicate(logs): foreign = logging.NullHandler() logging.getLogger().addHandler(foreign) logs.configure('INFO', 'text') logs.configure('INFO', 'text') assert foreign in logging.getLogger().handlers assert sum(bool(getattr(h, '_vent_handler', False)) for h in logging.getLogger().handlers) == 1 get_logger('uvicorn.error').info('服务器就绪', extra={'kv': {'event': 'server.ready', 'duration_ms': 12}}) get_logger('uvicorn.access').info('duplicate request') get_logger('runtime.concurrency').debug('并发细节') assert logs.getvalue().count('服务器就绪') == 1 assert 'event="server.ready"' in logs.getvalue() and 'duration_ms=12' in logs.getvalue() assert 'duplicate request' not in logs.getvalue() and '并发细节' not in logs.getvalue() @pytest.mark.parametrize('value', ['line\nbreak', 'a' * 129, '中文', '', None]) def test_invalid_trace_is_replaced(value): result = normalize_trace_id(value) assert len(result) == 32 and all(c in '0123456789abcdef' for c in result) @pytest.mark.asyncio async def test_context_isolated_between_tasks_and_propagates_to_threads(logs): async def run(name): token = bind_trace_id(name) try: with log_context(session_id=name): await asyncio.sleep(0) await asyncio.to_thread(get_logger('thread').info, '线程日志') finally: unbind_trace_id(token) await asyncio.gather(run('one'), run('two')) assert {(r['trace_id'], r['session_id']) for r in records(logs)} == {('one', 'one'), ('two', 'two')} assert not get_trace_id() and not get_log_context() def scope(path='/api/chat'): return {'type': 'http', 'method': 'POST', 'path': path, 'headers': [(b'x-trace-id', b'http-test')], 'query_string': b'api_key=never-log-query'} @pytest.mark.asyncio async def test_sse_request_logged_only_after_stream_and_cleanup(logs): first, finish = asyncio.Event(), asyncio.Event() sent = [] async def app(scope, receive, send): await send({'type': 'http.response.start', 'status': 200, 'headers': []}) await send({'type': 'http.response.body', 'body': b'data: secret-body\n\n', 'more_body': True}) first.set() await finish.wait() await send({'type': 'http.response.body', 'body': b'', 'more_body': False}) get_logger('cleanup').info('清理完成') async def receive(): return {'type': 'http.request', 'body': b'never-log-request'} async def send(message): sent.append(message) task = asyncio.create_task(RequestLogMiddleware(app)(scope(), receive, send)) await first.wait() assert records(logs) == [] finish.set() await task result = records(logs) assert result[-1]['event'] == 'http.request.completed' assert result[-1]['outcome'] == 'completed' and result[-1]['status_code'] == 200 assert result[-1]['duration_ms'] >= 0 assert all(r['trace_id'] == 'http-test' for r in result) assert (b'x-trace-id', b'http-test') in sent[0]['headers'] assert 'never-log' not in logs.getvalue() and 'secret-body' not in logs.getvalue() @pytest.mark.asyncio @pytest.mark.parametrize('mode,status,outcome', [('error', 500, 'error'), ('cancel', None, 'cancelled'), ('disconnect', None, 'disconnected')]) async def test_request_failures_logged_once_and_restore_context(logs, mode, status, outcome): async def app(scope, receive, send): if mode == 'error': raise ValueError('failed') if mode == 'cancel': raise asyncio.CancelledError await receive() async def receive(): return {'type': 'http.disconnect'} async def send(message): pass request = RequestLogMiddleware(app)(scope(), receive, send) if mode in {'error', 'cancel'}: with pytest.raises(ValueError if mode == 'error' else asyncio.CancelledError): await request else: await request row, = records(logs) assert row['status_code'] == status and row['outcome'] == outcome assert not get_trace_id() @pytest.mark.asyncio async def test_health_noise_is_debug_but_errors_are_visible(logs): logs.configure('INFO', 'json') status = 200 async def app(scope, receive, send): await send({'type': 'http.response.start', 'status': status}) await send({'type': 'http.response.body'}) async def noop(*args): pass await RequestLogMiddleware(app)(scope('/health'), noop, noop) assert records(logs) == [] status = 503 await RequestLogMiddleware(app)(scope('/health'), noop, noop) assert records(logs)[0]['level'] == 'ERROR' @pytest.mark.asyncio async def test_batch_logs_events_and_billing_share_trace(logs, monkeypatch, tmp_path, pricing_ready): import config from db import chat_store, billing_store, db_conn from runtime.execution import execute from runtime import concurrency from test_execution import Agent monkeypatch.setattr(config, 'ROOT', tmp_path) concurrency.reset_for_tests() sessions = [chat_store.create_session('log-user')['id'] for _ in range(2)] await asyncio.gather(*(execute(user_name='log-user', session_id=sid, message='never-log-prompt', agent=Agent()) for sid in sessions)) completed = [r for r in records(logs) if r.get('event') == 'execution.completed'] assert len(completed) == 2 assert {r['session_id'] for r in completed} == set(sessions) assert len({r['trace_id'] for r in completed}) == 2 for row in completed: assert row['status'] == 'success' and row['input_tokens'] == 8 usage = db_conn.query_one('SELECT trace_id FROM billing_usage WHERE id = ?', (row['run_id'],)) assert usage['trace_id'] == row['trace_id'] start = next(r for r in records(logs) if r.get('event') == 'execution.started' and r['run_id'] == row['run_id']) assert start['trace_id'] == row['trace_id'] assert 'never-log-prompt' not in logs.getvalue() assert not get_trace_id() and not get_log_context() @pytest.mark.asyncio async def test_real_http_response_header_and_access_log_match(logs, monkeypatch): import httpx from main import app # main 导入可能重新配置日志,将本例格式明确设回 JSON。 logs.configure('INFO', 'json') async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: response = await client.get('/api/no-such-route', headers={'X-Trace-Id': 'invalid trace'}) rows = [r for r in records(logs) if r.get('event') == 'http.request.completed'] assert len(rows) == 1 and rows[0]['status_code'] == 404 assert rows[0]['trace_id'] == response.headers['x-trace-id'] assert len(rows[0]['trace_id']) == 32 @pytest.mark.asyncio async def test_http_unhandled_exception_keeps_trace_and_stack(logs): import httpx from fastapi import FastAPI from main import unhandled_exception_handler app = FastAPI() app.add_middleware(RequestLogMiddleware) app.add_exception_handler(Exception, unhandled_exception_handler) @app.get('/broken') async def broken(): raise RuntimeError('password=hidden-error-password') logs.configure() async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, raise_app_exceptions=False), base_url='http://test') as client: response = await client.get('/broken', headers={'X-Trace-Id': 'broken-request'}) assert response.status_code == 500 assert response.headers['x-trace-id'] == 'broken-request' related = [r for r in records(logs) if r.get('event', '').startswith('http.request.')] assert len(related) == 2 # 一条请求汇总、一条错误堆栈。 assert all(r['trace_id'] == 'broken-request' for r in related) assert 'Traceback' in next(r for r in related if r['event'] == 'http.request.failed')['exc'] assert 'hidden-error-password' not in logs.getvalue() @pytest.mark.asyncio async def test_execution_event_trace_matches_logs_when_not_provided(logs, monkeypatch, tmp_path, pricing_ready): import config from db import chat_store from runtime.execution import admit, build_execution from test_execution import Agent monkeypatch.setattr(config, 'ROOT', tmp_path) sid = chat_store.create_session('event-user')['id'] admission = await admit('event-user', sid) execution = await build_execution(admission, agent=Agent(), session_id=sid, thread_id=sid, user_name='event-user', message='问题', config={'configurable': {'thread_id': sid}}) events = [event async for event in execution.events()] assert events and all(event['trace_id'] == execution.req.trace_id for event in events) completed = [r for r in records(logs) if r.get('event') == 'execution.completed'] assert len(completed) == 1 and completed[0]['trace_id'] == execution.req.trace_id assert not get_trace_id() and not get_log_context() @pytest.mark.asyncio async def test_schedule_and_execution_share_trace(logs, monkeypatch, tmp_path, pricing_ready): import config from db import schedule_store from scheduler.runner import run_schedule from test_execution import Agent monkeypatch.setattr(config, 'ROOT', tmp_path) monkeypatch.setattr('agents.agent_registry.get_agent', lambda: Agent()) schedule = schedule_store.create_schedule('log-user', 'report', '0 9 * * *', 'never-log-schedule-prompt') result = await run_schedule(schedule['id']) assert result['ok'] run = next(r for r in records(logs) if r.get('event') == 'execution.completed') scheduled = next(r for r in records(logs) if r.get('event') == 'schedule.completed') assert run['trace_id'] == scheduled['trace_id'] != '-' assert run['schedule_id'] == scheduled['schedule_id'] == schedule['id'] assert run['session_id'] == scheduled['session_id'] == result['session_id'] assert scheduled['schedule_run_id'] == result['run_id'] assert 'never-log-schedule-prompt' not in logs.getvalue() assert not get_trace_id() and not get_log_context()