Просмотр исходного кода

feat: 统一日志格式与执行链路追踪

gaoshutao 1 месяц назад
Родитель
Сommit
c0c56035ee

+ 2 - 0
.env.example

@@ -29,7 +29,9 @@ DEEPAGENT_THINKING=off
 # ============================================================
 # ============================================================
 HOST=0.0.0.0
 HOST=0.0.0.0
 PORT=8080
 PORT=8080
+# INFO:启动、任务及请求汇总;DEBUG:含并发位、健康检查等细节
 LOG_LEVEL=INFO
 LOG_LEVEL=INFO
+# text:本地阅读;json:每行一条结构化日志,适合采集
 LOG_FORMAT=text
 LOG_FORMAT=text
 
 
 # ============================================================
 # ============================================================

+ 2 - 0
README.md

@@ -130,6 +130,8 @@ DEEP_AGENT_ENV_PRIORITY=process LOG_LEVEL=DEBUG ~/vent-agent/current/release/ser
 
 
 **目前没有自动的数据迁移、兼容性检查或备份。** 回退只切换代码和依赖,不回滚数据库或 Agent checkpoint;结构不兼容时不能直接切回旧版。完整命令见 [升级与回退](release/README.md#升级与回退)。
 **目前没有自动的数据迁移、兼容性检查或备份。** 回退只切换代码和依赖,不回滚数据库或 Agent checkpoint;结构不兼容时不能直接切回旧版。完整命令见 [升级与回退](release/README.md#升级与回退)。
 
 
+日志默认使用 `INFO` + `text`;设置 `LOG_FORMAT=json` 可输出结构化日志。请求与任务通过 `trace_id` 关联,任务还带 `session_id`、`run_id`、状态和耗时。健康检查、静态资源成功请求及并发位细节在 `DEBUG` 查看。配置与排查示例见 [日志说明](docs/DEVELOPMENT.md#日志与排查)。
+
 ## 7. 业务扩展与开发验证
 ## 7. 业务扩展与开发验证
 
 
 聊天、定时任务和批处理共用 `runtime/` 执行层;HTTP 层负责 SSE 编码。独立脚本使用 `RuntimeEnvironment` 自动初始化和清理资源,默认不启动调度器。调用方式见 [执行层与入口](docs/DEVELOPMENT.md#执行层与入口)。
 聊天、定时任务和批处理共用 `runtime/` 执行层;HTTP 层负责 SSE 编码。独立脚本使用 `RuntimeEnvironment` 自动初始化和清理资源,默认不启动调度器。调用方式见 [执行层与入口](docs/DEVELOPMENT.md#执行层与入口)。

+ 3 - 3
agents/agent_registry.py

@@ -50,7 +50,7 @@ class _AgentSlot:
         with self._lock:
         with self._lock:
             # 二次检查:可能在等锁期间已被别的线程编译好
             # 二次检查:可能在等锁期间已被别的线程编译好
             if self._agent is None:
             if self._agent is None:
-                logger.info("首次编译 agent(generation=%d)", self._generation + 1)
+                logger.debug("首次编译 agent(generation=%d)", self._generation + 1)
                 self._agent = self._compile()
                 self._agent = self._compile()
                 self._generation += 1
                 self._generation += 1
                 logger.info("agent 编译完成(generation=%d)", self._generation)
                 logger.info("agent 编译完成(generation=%d)", self._generation)
@@ -66,7 +66,7 @@ class _AgentSlot:
         with self._lock:
         with self._lock:
             self._agent = None
             self._agent = None
             self._generation += 1
             self._generation += 1
-            logger.info("agent 已失效,下次请求将重建(generation=%d)", self._generation)
+            logger.debug("agent 已失效,下次请求将重建(generation=%d)", self._generation)
             return self._generation
             return self._generation
 
 
 
 
@@ -106,5 +106,5 @@ async def prewarm() -> bool:
         await asyncio.to_thread(get_agent)
         await asyncio.to_thread(get_agent)
         return True
         return True
     except Exception as exc:
     except Exception as exc:
-        logger.error("agent 预热失败,将在首次请求时重试:%s: %s", type(exc).__name__, exc)
+        logger.exception("agent 预热失败,将在首次请求时重试", extra={"kv": {"event": "agent.prewarm_failed"}})
         return False
         return False

+ 71 - 0
api/request_logging.py

@@ -0,0 +1,71 @@
+"""ASGI 请求日志:覆盖完整 SSE 生命周期,不读取请求正文或查询参数。"""
+import asyncio
+import logging
+import time
+
+from obs import bind_trace_id, unbind_trace_id, normalize_trace_id, get_logger
+
+logger = get_logger('api.access')
+
+
+class RequestLogMiddleware:
+    def __init__(self, app, *, trace_enabled=True):
+        self.app = app
+        self.trace_enabled = trace_enabled
+
+    async def __call__(self, scope, receive, send):
+        if scope['type'] != 'http':
+            return await self.app(scope, receive, send)
+        started = time.perf_counter()
+        headers = dict(scope.get('headers', []))
+        trace_id = normalize_trace_id(headers.get(b'x-trace-id', b'').decode('latin1'))
+        token = bind_trace_id(trace_id) if self.trace_enabled else None
+        if self.trace_enabled:
+            scope.setdefault('state', {})['trace_id'] = trace_id
+        status, completed, disconnected, failure = None, False, False, None
+
+        async def logged_receive():
+            nonlocal disconnected
+            message = await receive()
+            if message['type'] == 'http.disconnect':
+                disconnected = True
+            return message
+
+        async def logged_send(message):
+            nonlocal status, completed
+            if message['type'] == 'http.response.start':
+                status = message['status']
+                if self.trace_enabled:
+                    message = {**message, 'headers': [(k, v) for k, v in message.get('headers', [])
+                                                       if k.lower() != b'x-trace-id']
+                               + [(b'x-trace-id', trace_id.encode('ascii'))]}
+            await send(message)
+            if message['type'] == 'http.response.body' and not message.get('more_body', False):
+                completed = True
+
+        try:
+            await self.app(scope, logged_receive, logged_send)
+        except BaseException as exc:
+            failure = exc
+            raise
+        finally:
+            try:
+                outcome = ('cancelled' if isinstance(failure, asyncio.CancelledError)
+                           else 'error' if failure else 'completed' if completed
+                           else 'disconnected' if disconnected else 'incomplete')
+                code = status if status is not None else (500 if failure and outcome == 'error' else None)
+                level = logging.ERROR if outcome == 'error' or (code and code >= 500) else (
+                    logging.WARNING if outcome != 'completed' or (code and code >= 400) else logging.INFO)
+                path = scope.get('path', '')
+                if level == logging.INFO and (path == '/health' or path.startswith('/static/') or path == '/favicon.ico'):
+                    level = logging.DEBUG
+                route = scope.get('route')
+                logger.log(level, '请求结束', extra={'kv': {
+                    'event': 'http.request.completed', 'method': scope.get('method'),
+                    'path': getattr(route, 'path', path), 'status_code': code, 'outcome': outcome,
+                    'duration_ms': round((time.perf_counter() - started) * 1000, 1),
+                    **({'error_type': type(failure).__name__} if failure else {}),
+                }})
+            finally:
+                if token is not None:
+                    unbind_trace_id(token)

+ 11 - 3
billing/meter.py

@@ -99,7 +99,7 @@ class UsageMeter:
                 self.model, self.input_tokens, self.output_tokens
                 self.model, self.input_tokens, self.output_tokens
             )
             )
         except Exception as exc:
         except Exception as exc:
-            logger.error(
+            logger.exception(
                 "计价失败未入账 user=%s model=%s tokens=%d/%d err=%s: %s",
                 "计价失败未入账 user=%s model=%s tokens=%d/%d err=%s: %s",
                 self.user_name, self.model, self.input_tokens, self.output_tokens,
                 self.user_name, self.model, self.input_tokens, self.output_tokens,
                 type(exc).__name__, exc,
                 type(exc).__name__, exc,
@@ -120,7 +120,7 @@ class UsageMeter:
                 usage_id=self._usage_id,
                 usage_id=self._usage_id,
             )
             )
         except Exception as exc:
         except Exception as exc:
-            logger.error(
+            logger.exception(
                 "入账失败 user=%s session=%s tokens=%d/%d err=%s: %s",
                 "入账失败 user=%s session=%s tokens=%d/%d err=%s: %s",
                 self.user_name, self.session_id, self.input_tokens, self.output_tokens,
                 self.user_name, self.session_id, self.input_tokens, self.output_tokens,
                 type(exc).__name__, exc,
                 type(exc).__name__, exc,
@@ -137,6 +137,8 @@ class UsageMeter:
             "入账完成 user=%s kind=%s model=%s tokens=%d/%d cost=%.6f%s source=%s",
             "入账完成 user=%s kind=%s model=%s tokens=%d/%d cost=%.6f%s source=%s",
             self.user_name, self.kind, self.model,
             self.user_name, self.kind, self.model,
             self.input_tokens, self.output_tokens, cost, currency, self.usage_source,
             self.input_tokens, self.output_tokens, cost, currency, self.usage_source,
+            extra={"kv": {"event": "billing.settled", "usage_id": usage_id,
+                          "session_id": self.session_id, "cost": cost, "currency": currency}},
         )
         )
         return {
         return {
             "settled": True,
             "settled": True,
@@ -160,7 +162,13 @@ def retry_pending() -> int:
             source = payload.pop("source")
             source = payload.pop("source")
             meter = UsageMeter(**payload)
             meter = UsageMeter(**payload)
             meter.add(inputs, outputs, source)
             meter.add(inputs, outputs, source)
-            settled += bool(meter.settle().get("settled"))
+            from obs import bind_trace_id, unbind_trace_id, log_context
+            token = bind_trace_id(meter.trace_id)
+            try:
+                with log_context(run_id=meter._usage_id, session_id=meter.session_id, kind=meter.kind):
+                    settled += bool(meter.settle().get("settled"))
+            finally:
+                unbind_trace_id(token)
         except Exception:
         except Exception:
             logger.exception("待结算记录恢复失败 id=%s", row["id"])
             logger.exception("待结算记录恢复失败 id=%s", row["id"])
     return settled
     return settled

+ 49 - 0
docs/DEVELOPMENT.md

@@ -230,6 +230,55 @@ if __name__ == "__main__":
 
 
 ---
 ---
 
 
+## 日志与排查
+
+继续使用 `LOG_LEVEL`(默认 `INFO`)与 `LOG_FORMAT`(`text` / `json`)。业务日志和 Uvicorn 日志使用相同格式、毫秒时间戳与时区;发布脚本也读取配置中的日志级别。输出仍走标准输出,部署脚本写入 `shared/logs/server.log`,systemd 使用 journal。
+
+| 级别 | 内容 |
+|---|---|
+| INFO | 运行环境/扩展服务就绪、执行开始与结束、计费、业务请求汇总 |
+| WARNING | 额度不足、取消、4xx、估算用量等需要留意的情况 |
+| ERROR | 执行或收尾失败、5xx、落库与结算异常;关键异常保留堆栈 |
+| DEBUG | 并发位申请释放、缓存失效、健康检查及静态资源成功请求 |
+
+常见第三方 HTTP 客户端、数据库驱动和调度器内部日志仍保持 WARNING,避免调试业务时输出大量底层细节。
+
+### 按一次执行定位问题
+
+1. 从响应头 `X-Trace-Id` 或 SSE 事件取得 `trace_id`。也可以传入由字母、数字、点、下划线、冒号和连字符组成的 1–128 位 ID;非法值会替换为新 ID。
+2. 按该 ID 搜索日志,再按 `run_id` 区分具体执行、按 `session_id` 关联会话。定时任务还包含 `schedule_id`,其调度记录 ID 为 `schedule_run_id`。
+3. 查看 `execution.completed` 的 `status`、`duration_ms`、输入/输出 token 和 `error_code`,异常详情看同一 trace 下的堆栈。
+
+`http.request.completed` 在响应流和应用清理结束后记录一次,因此 SSE 耗时包含整个连接过程。HTTP 200 只代表流已建立,Agent 是否成功要看 `execution.completed`。健康接口当前可能以 HTTP 200 返回 `status=down`;日志降噪按 HTTP 状态码判断,监控仍需检查响应内容。
+
+独立批处理自动生成 trace;定时执行和后台结算重试也会携带关联 ID。并发请求的上下文互相隔离,传到 `asyncio.to_thread()` 的数据库/计费日志沿用同一上下文。
+
+JSON 日志可这样筛选(服务器装有 `jq` 时):
+
+```bash
+jq -c 'select(.trace_id == "这里填写trace_id")' ~/vent-agent/shared/logs/server.log
+jq -c 'select(.event == "execution.completed" and .status == "failed")' ~/vent-agent/shared/logs/server.log
+```
+
+以上命令适用于文件内容均为 JSON 的情况;切换格式前留下的文本日志可用 `rg 'trace_id值' 文件路径` 搜索。生产文件的保留与轮转由部署环境管理,本次没有新增第二份应用日志文件。
+
+### 扩展模块如何记日志
+
+```python
+from obs import get_logger
+
+logger = get_logger(__name__)
+logger.info("检索完成", extra={"kv": {
+    "event": "rag.search.completed", "hits": 5, "duration_ms": 38,
+}})
+```
+
+`kv` 在文本和 JSON 中都会输出。`ts`、`level`、`logger`、`trace_id`、`message`、`exc` 是保留字段,不能通过 `kv` 覆盖;当前绑定的执行身份优先。异常处理使用 `logger.exception()` 保留堆栈。
+
+请求日志不记录正文、查询参数、Cookie 或 Authorization;任务摘要不记录提示词、回答和工具参数。框架输出会遮蔽结构化凭证字段,以及消息/堆栈中常见的密钥赋值、Bearer 凭证和 URL 用户密码。扩展仍应只记录排查所需元数据,避免把整段业务数据或客户端配置直接写入日志。
+
+---
+
 ## 扩展方式
 ## 扩展方式
 
 
 ### 新增业务 Agent
 ### 新增业务 Agent

+ 9 - 0
extensions/services.py

@@ -4,6 +4,11 @@ from __future__ import annotations
 from contextlib import AsyncExitStack
 from contextlib import AsyncExitStack
 from dataclasses import dataclass
 from dataclasses import dataclass
 import inspect
 import inspect
+import time
+
+from obs import get_logger
+
+logger = get_logger(__name__)
 from types import MappingProxyType
 from types import MappingProxyType
 from typing import Any, AsyncContextManager, Callable, Mapping
 from typing import Any, AsyncContextManager, Callable, Mapping
 
 
@@ -56,6 +61,8 @@ class ServiceContainer:
         order = ordered_names({name: set(spec.requires) for name, spec in self._specs.items()}, "服务")
         order = ordered_names({name: set(spec.requires) for name, spec in self._specs.items()}, "服务")
         for name in order:
         for name in order:
             spec = self._specs[name]
             spec = self._specs[name]
+            started = time.perf_counter()
+            logger.debug("扩展服务启动", extra={"kv": {"event": "service.starting", "service": name}})
             dependencies = self.view(spec.requires)
             dependencies = self.view(spec.requires)
             context = spec.factory(dependencies)
             context = spec.factory(dependencies)
             if not hasattr(context, "__aenter__") or not hasattr(context, "__aexit__"):
             if not hasattr(context, "__aenter__") or not hasattr(context, "__aexit__"):
@@ -63,6 +70,8 @@ class ServiceContainer:
                     context.close()
                     context.close()
                 raise TypeError(f"服务 {name} 的工厂必须返回异步上下文管理器")
                 raise TypeError(f"服务 {name} 的工厂必须返回异步上下文管理器")
             self._values[name] = await self._stack.enter_async_context(context)
             self._values[name] = await self._stack.enter_async_context(context)
+            logger.info("扩展服务就绪", extra={"kv": {"event": "service.ready", "service": name,
+                        "duration_ms": round((time.perf_counter() - started) * 1000, 1)}})
         _active = self
         _active = self
 
 
     def view(self, names) -> Mapping[str, Any]:
     def view(self, names) -> Mapping[str, Any]:

+ 14 - 22
main.py

@@ -11,11 +11,12 @@ from fastapi.staticfiles import StaticFiles
 
 
 import config
 import config
 from api import router as api_router
 from api import router as api_router
+from api.request_logging import RequestLogMiddleware
 from runtime import concurrency
 from runtime import concurrency
 from runtime.environment import RuntimeEnvironment
 from runtime.environment import RuntimeEnvironment
 from billing import guard
 from billing import guard
 from db import db_conn
 from db import db_conn
-from obs import bind_trace_id, get_logger, new_trace_id, setup_logging, unbind_trace_id
+from obs import bind_trace_id, get_logger, setup_logging, unbind_trace_id
 
 
 setup_logging(config.LOG_LEVEL, config.LOG_FORMAT)
 setup_logging(config.LOG_LEVEL, config.LOG_FORMAT)
 logger = get_logger(__name__)
 logger = get_logger(__name__)
@@ -45,7 +46,8 @@ async def quota_error(request: Request, exc: guard.QuotaExceededError):
 
 
 @app.exception_handler(guard.BillingConfigError)
 @app.exception_handler(guard.BillingConfigError)
 async def billing_error(request: Request, exc: guard.BillingConfigError):
 async def billing_error(request: Request, exc: guard.BillingConfigError):
-    logger.error("计费准入失败:%s", exc)
+    logger.error("计费准入失败", exc_info=(type(exc), exc, exc.__traceback__),
+                 extra={"kv": {"event": "billing.admission_failed"}})
     return JSONResponse(status_code=503, content={"detail": {"code": "billing_unavailable", "message": "计费暂不可用"}})
     return JSONResponse(status_code=503, content={"detail": {"code": "billing_unavailable", "message": "计费暂不可用"}})
 
 
 
 
@@ -68,25 +70,7 @@ async def session_missing(request: Request, exc: concurrency.SessionMissingError
                         content={"detail": {"code": "session_not_found", "message": str(exc)}})
                         content={"detail": {"code": "session_not_found", "message": str(exc)}})
 
 
 
 
-if config.TRACE_MIDDLEWARE:
-
-    @app.middleware("http")
-    async def trace_middleware(request: Request, call_next):
-        """把 ``X-Trace-Id`` 透传进来或新生成一个,回写响应头。
-
-        注意:这里绑定的 ContextVar 在 StreamingResponse 的生成器里**不保证**
-        可见(响应体迭代发生在中间件调用栈之外),所以 SSE 生成器内部会自己
-        再绑一次。这个中间件的作用是让普通 HTTP 接口与访问日志带上 trace_id。
-        """
-        trace_id = request.headers.get("X-Trace-Id") or new_trace_id()
-        request.state.trace_id = trace_id
-        token = bind_trace_id(trace_id)
-        try:
-            response = await call_next(request)
-        finally:
-            unbind_trace_id(token)
-        response.headers["X-Trace-Id"] = trace_id
-        return response
+app.add_middleware(RequestLogMiddleware, trace_enabled=config.TRACE_MIDDLEWARE)
 
 
 
 
 @app.exception_handler(Exception)
 @app.exception_handler(Exception)
@@ -96,10 +80,17 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
     不返回堆栈——它可能含连接串、内网地址。堆栈只进日志,用 trace_id 关联。
     不返回堆栈——它可能含连接串、内网地址。堆栈只进日志,用 trace_id 关联。
     """
     """
     trace_id = getattr(request.state, "trace_id", "")
     trace_id = getattr(request.state, "trace_id", "")
-    logger.exception("未处理异常 path=%s trace=%s", request.url.path, trace_id)
+    token = bind_trace_id(trace_id)
+    try:
+        logger.exception("请求处理异常", extra={"kv": {"event": "http.request.failed",
+                         "method": request.method, "path": request.url.path,
+                         "error_type": type(exc).__name__}})
+    finally:
+        unbind_trace_id(token)
     return JSONResponse(
     return JSONResponse(
         status_code=500,
         status_code=500,
         content={"code": "internal_error", "message": f"服务内部错误:{type(exc).__name__}"},
         content={"code": "internal_error", "message": f"服务内部错误:{type(exc).__name__}"},
+        headers={"X-Trace-Id": trace_id} if trace_id else None,
     )
     )
 
 
 
 
@@ -177,4 +168,5 @@ if __name__ == "__main__":
         workers=1,  # 单进程是硬约束,见 _check_single_process
         workers=1,  # 单进程是硬约束,见 _check_single_process
         reload=False,  # 开 reload 会起子进程,同样破坏进程内状态
         reload=False,  # 开 reload 会起子进程,同样破坏进程内状态
         log_level=config.LOG_LEVEL.lower(),
         log_level=config.LOG_LEVEL.lower(),
+        log_config=None, access_log=False,
     )
     )

+ 2 - 0
obs/__init__.py

@@ -3,6 +3,7 @@
 from obs.log_setup import get_logger, setup_logging
 from obs.log_setup import get_logger, setup_logging
 from obs.tracing import (
 from obs.tracing import (
     bind_trace_id,
     bind_trace_id,
+    bind_log_context, unbind_log_context, get_log_context, log_context, normalize_trace_id,
     get_trace_id,
     get_trace_id,
     new_trace_id,
     new_trace_id,
     trace_headers,
     trace_headers,
@@ -11,6 +12,7 @@ from obs.tracing import (
 
 
 __all__ = [
 __all__ = [
     "get_logger",
     "get_logger",
+    "bind_log_context", "unbind_log_context", "get_log_context", "log_context", "normalize_trace_id",
     "setup_logging",
     "setup_logging",
     "bind_trace_id",
     "bind_trace_id",
     "get_trace_id",
     "get_trace_id",

+ 81 - 57
obs/log_setup.py

@@ -1,85 +1,109 @@
-"""日志装配。
-
-两个格式档位:
-- ``text``(默认):人类可读,本地开发用 ``tail`` 看
-- ``json``:一行一条 JSON,便于采集到 ELK/Loki
-
-trace_id 通过 ``obs.tracing.get_trace_id`` 动态注入——用 filter 而非
-formatter 参数,是为了让每条日志自动带上当前上下文的 trace_id,调用方
-不需要在每条 log 里手动传。
-"""
-
+"""统一业务与服务器日志:文本便于阅读,JSON 便于采集。"""
 from __future__ import annotations
 from __future__ import annotations
 
 
 import json
 import json
 import logging
 import logging
+import re
 import sys
 import sys
 from datetime import datetime
 from datetime import datetime
 
 
-from obs.tracing import get_trace_id
+from obs.tracing import get_trace_id, get_log_context
 
 
-_TEXT_FMT = "%(asctime)s %(levelname)-5s [%(trace_id)s] %(name)s | %(message)s"
-_DATE_FMT = "%Y-%m-%d %H:%M:%S"
+_NOISY_LOGGERS = ('httpx', 'httpcore', 'urllib3', 'openai', 'aiosqlite',
+                  'apscheduler.executors.default', 'apscheduler.scheduler',
+                  'langchain_openai.chat_models._client_utils')
+_RESERVED = {'ts', 'level', 'logger', 'trace_id', 'message', 'exc'}
+_SECRET_KEYS = {'authorization', 'proxy_authorization', 'api_key', 'openai_api_key',
+                'password', 'secret', 'token', 'access_token', 'refresh_token', 'cookie', 'set_cookie'}
+_ASSIGNMENT = re.compile(r'(?i)((?:api[_-]?key|password|access[_-]?token|refresh[_-]?token|secret)\s*[=:]\s*[\"\']?)[^\s,;\"\'&]+')
+_BEARER = re.compile(r'(?i)(bearer\s+)[A-Za-z0-9._~+/-]+=*')
+_URL_CREDENTIALS = re.compile(r'(://)[^\s/@]+:[^\s/@]+@')
 
 
-# 这些第三方 logger 在 INFO 级别过于聒噪,统一压到 WARNING
-_NOISY_LOGGERS = (
-    "httpx",
-    "httpcore",
-    "urllib3",
-    "apscheduler.executors.default",
-    "apscheduler.scheduler",
-    "openai",
-)
 
 
+def _safe(value):
+    if isinstance(value, dict):
+        return {str(k): '[REDACTED]' if str(k).lower().replace('-', '_') in _SECRET_KEYS else _safe(v)
+                for k, v in value.items()}
+    if isinstance(value, (list, tuple)):
+        return [_safe(item) for item in value]
+    if isinstance(value, str):
+        value = _ASSIGNMENT.sub(r'\1[REDACTED]', value)
+        value = _BEARER.sub(r'\1[REDACTED]', value)
+        return _URL_CREDENTIALS.sub(r'\1[REDACTED]@', value)
+    return value
 
 
-class _TraceIdFilter(logging.Filter):
-    """把当前上下文的 trace_id 注入 LogRecord,缺省为 '-'。"""
 
 
-    def filter(self, record: logging.LogRecord) -> bool:
-        record.trace_id = get_trace_id() or "-"
+class _TraceIdFilter(logging.Filter):
+    def filter(self, record):
+        record.trace_id = get_trace_id() or '-'
+        record.log_context = get_log_context()
         return True
         return True
 
 
 
 
+def _payload(record):
+    payload = {
+        'ts': datetime.fromtimestamp(record.created).astimezone().isoformat(timespec='milliseconds'),
+        'level': record.levelname,
+        'logger': record.name,
+        'trace_id': getattr(record, 'trace_id', '-'),
+        'message': _safe(record.getMessage()),
+    }
+    context = getattr(record, 'log_context', {})
+    kv = getattr(record, 'kv', {})
+    if isinstance(kv, dict):
+        payload.update({k: v for k, v in _safe(kv).items() if k not in _RESERVED})
+    # 绑定的执行身份优先于临时字段,防止覆盖关联信息。
+    payload.update({k: v for k, v in _safe(context).items() if k not in _RESERVED})
+    if record.exc_info:
+        payload['exc'] = _safe(logging.Formatter().formatException(record.exc_info))
+    if record.stack_info:
+        payload['stack'] = _safe(record.stack_info)
+    return payload
+
+
 class _JsonFormatter(logging.Formatter):
 class _JsonFormatter(logging.Formatter):
-    """一行一条 JSON 的格式化器。"""
-
-    def format(self, record: logging.LogRecord) -> str:
-        payload = {
-            "ts": datetime.fromtimestamp(record.created).isoformat(timespec="milliseconds"),
-            "level": record.levelname,
-            "logger": record.name,
-            "trace_id": getattr(record, "trace_id", "-"),
-            "message": record.getMessage(),
-        }
-        if record.exc_info:
-            payload["exc"] = self.formatException(record.exc_info)
-        # 允许调用方通过 logger.info("...", extra={"kv": {...}}) 附加结构化字段
-        kv = getattr(record, "kv", None)
-        if isinstance(kv, dict):
-            payload.update(kv)
-        return json.dumps(payload, ensure_ascii=False)
-
-
-def setup_logging(level: str = "INFO", fmt: str = "text") -> None:
-    """初始化根 logger。重复调用安全(先清空已有 handler)。"""
+    def format(self, record):
+        return json.dumps(_payload(record), ensure_ascii=False, default=str)
+
+
+class _TextFormatter(logging.Formatter):
+    def format(self, record):
+        p = _payload(record)
+        # JSON 转义保持一条普通日志一行;异常堆栈在下一行完整呈现。
+        message = str(p['message']).replace('\r', r'\r').replace('\n', r'\n')
+        fields = ' '.join(f'{key}={json.dumps(value, ensure_ascii=False, default=str)}'
+                          for key, value in p.items() if key not in _RESERVED)
+        line = f"{p['ts']} {p['level']:<7} [{p['trace_id']}] {p['logger']} | {message}"
+        if fields:
+            line += ' | ' + fields
+        if p.get('exc'):
+            line += '\n' + p['exc']
+        return line
+
+
+def setup_logging(level: str = 'INFO', fmt: str = 'text') -> None:
+    """重复配置只替换自身 handler,保留测试/宿主添加的采集器。"""
     root = logging.getLogger()
     root = logging.getLogger()
     root.setLevel(getattr(logging, str(level).upper(), logging.INFO))
     root.setLevel(getattr(logging, str(level).upper(), logging.INFO))
-
     for handler in list(root.handlers):
     for handler in list(root.handlers):
-        root.removeHandler(handler)
-
+        if getattr(handler, '_vent_handler', False):
+            root.removeHandler(handler)
+            handler.close()
     handler = logging.StreamHandler(sys.stdout)
     handler = logging.StreamHandler(sys.stdout)
+    handler._vent_handler = True
     handler.addFilter(_TraceIdFilter())
     handler.addFilter(_TraceIdFilter())
-    if str(fmt).lower() == "json":
-        handler.setFormatter(_JsonFormatter())
-    else:
-        handler.setFormatter(logging.Formatter(_TEXT_FMT, datefmt=_DATE_FMT))
+    handler.setFormatter(_JsonFormatter() if str(fmt).lower() == 'json' else _TextFormatter())
     root.addHandler(handler)
     root.addHandler(handler)
-
     for name in _NOISY_LOGGERS:
     for name in _NOISY_LOGGERS:
         logging.getLogger(name).setLevel(logging.WARNING)
         logging.getLogger(name).setLevel(logging.WARNING)
+    for name in ('uvicorn', 'uvicorn.error', 'uvicorn.access'):
+        server = logging.getLogger(name)
+        server.handlers.clear()
+        server.propagate = True
+        server.setLevel(logging.NOTSET)
+    # 请求汇总由 ASGI 中间件记录,包含流结束时间,避免 Uvicorn 再记一遍。
+    logging.getLogger('uvicorn.access').disabled = True
 
 
 
 
 def get_logger(name: str) -> logging.Logger:
 def get_logger(name: str) -> logging.Logger:
-    """取 logger 的统一入口(留一层薄封装,将来换实现只改这里)。"""
     return logging.getLogger(name)
     return logging.getLogger(name)

+ 33 - 1
obs/tracing.py

@@ -9,11 +9,43 @@ ContextVar 自身保证。
 from __future__ import annotations
 from __future__ import annotations
 
 
 import uuid
 import uuid
+import re
+from contextlib import contextmanager
 from contextvars import ContextVar
 from contextvars import ContextVar
 from typing import Any
 from typing import Any
 
 
 # 当前请求的 trace_id;未绑定时为空串(而非 None,省掉调用方的判空)
 # 当前请求的 trace_id;未绑定时为空串(而非 None,省掉调用方的判空)
 _trace_id: ContextVar[str] = ContextVar("trace_id", default="")
 _trace_id: ContextVar[str] = ContextVar("trace_id", default="")
+_context: ContextVar[dict[str, Any]] = ContextVar("log_context", default={})
+_TRACE_PATTERN = re.compile(r"[A-Za-z0-9._:-]{1,128}\Z")
+
+
+def normalize_trace_id(value: str | None) -> str:
+    return value if isinstance(value, str) and _TRACE_PATTERN.fullmatch(value) else new_trace_id()
+
+
+def get_log_context() -> dict[str, Any]:
+    return dict(_context.get())
+
+
+def bind_log_context(**fields):
+    return _context.set({**_context.get(), **fields})
+
+
+def unbind_log_context(token) -> None:
+    try:
+        _context.reset(token)
+    except (ValueError, LookupError):
+        pass
+
+
+@contextmanager
+def log_context(**fields):
+    token = bind_log_context(**fields)
+    try:
+        yield
+    finally:
+        unbind_log_context(token)
 
 
 
 
 def new_trace_id() -> str:
 def new_trace_id() -> str:
@@ -32,7 +64,7 @@ def bind_trace_id(trace_id: str | None) -> Any:
     调用方应在 finally 中 ``unbind_trace_id(token)``。用 token 还原而不是
     调用方应在 finally 中 ``unbind_trace_id(token)``。用 token 还原而不是
     直接重置为空串,是为了支持嵌套场景(外层已有 trace 时内层不要抹掉)。
     直接重置为空串,是为了支持嵌套场景(外层已有 trace 时内层不要抹掉)。
     """
     """
-    return _trace_id.set(trace_id or new_trace_id())
+    return _trace_id.set(normalize_trace_id(trace_id))
 
 
 
 
 def unbind_trace_id(token: Any) -> None:
 def unbind_trace_id(token: Any) -> None:

+ 2 - 1
release/deploy.py

@@ -171,7 +171,8 @@ def serve(prefix: Path):
         os.replace(temporary, path)
         os.replace(temporary, path)
         try:
         try:
             uvicorn.run("main:app", host=config.HOST, port=config.PORT, workers=1,
             uvicorn.run("main:app", host=config.HOST, port=config.PORT, workers=1,
-                        timeout_graceful_shutdown=60, log_level="info")
+                        timeout_graceful_shutdown=60, log_level=config.LOG_LEVEL.lower(),
+                        log_config=None, access_log=False)
         finally:
         finally:
             if read_record(prefix).get("pid") == os.getpid():
             if read_record(prefix).get("pid") == os.getpid():
                 path.unlink(missing_ok=True)
                 path.unlink(missing_ok=True)

+ 2 - 2
runtime/concurrency.py

@@ -114,7 +114,7 @@ class TaskSlot:
                 _per_user_count.pop(self.user_name, None)
                 _per_user_count.pop(self.user_name, None)
             else:
             else:
                 _per_user_count[self.user_name] = user_count - 1
                 _per_user_count[self.user_name] = user_count - 1
-        logger.info(
+        logger.debug(
             "释放并发位 user=%s 持有时长=%.1fs 剩余全局=%d 剩余用户=%d",
             "释放并发位 user=%s 持有时长=%.1fs 剩余全局=%d 剩余用户=%d",
             self.user_name, self.held_seconds, _global_count[0],
             self.user_name, self.held_seconds, _global_count[0],
             _per_user_count.get(self.user_name, 0),
             _per_user_count.get(self.user_name, 0),
@@ -145,7 +145,7 @@ def acquire_task_slot(user_name: str) -> TaskSlot:
             )
             )
         _global_count[0] += 1
         _global_count[0] += 1
         _per_user_count[user] = current_user + 1
         _per_user_count[user] = current_user + 1
-        logger.info(
+        logger.debug(
             "占用并发位 user=%s 全局=%d/%d 用户=%d/%d",
             "占用并发位 user=%s 全局=%d/%d 用户=%d/%d",
             user, _global_count[0], config.MAX_CONCURRENT_TASKS,
             user, _global_count[0], config.MAX_CONCURRENT_TASKS,
             _per_user_count[user], config.MAX_USER_TASKS,
             _per_user_count[user], config.MAX_USER_TASKS,

+ 35 - 12
runtime/engine.py

@@ -24,7 +24,7 @@ from runtime.source_tracker import SourceTracker
 from runtime.subagent_tracker import SubagentTracker
 from runtime.subagent_tracker import SubagentTracker
 from billing.meter import UsageMeter
 from billing.meter import UsageMeter
 from db import chat_store, db_conn, run_store
 from db import chat_store, db_conn, run_store
-from obs import get_logger, get_trace_id
+from obs import get_logger, get_trace_id, normalize_trace_id, bind_log_context, unbind_log_context
 
 
 logger = get_logger(__name__)
 logger = get_logger(__name__)
 
 
@@ -118,6 +118,7 @@ class Execution:
 
 
     def __init__(self, request: ExecutionRequest) -> None:
     def __init__(self, request: ExecutionRequest) -> None:
         self.req = request
         self.req = request
+        request.trace_id = normalize_trace_id(request.trace_id or get_trace_id())
         self.writer = EventBuilder(request.session_id, request.trace_id or get_trace_id(), encode=lambda event: event)
         self.writer = EventBuilder(request.session_id, request.trace_id or get_trace_id(), encode=lambda event: event)
         self.meter = _meter_for_request()
         self.meter = _meter_for_request()
         self.tracker = SubagentTracker()
         self.tracker = SubagentTracker()
@@ -176,7 +177,11 @@ class Execution:
         if self._consumed:
         if self._consumed:
             raise RuntimeError("同一任务只能执行一次,请创建新的 Execution")
             raise RuntimeError("同一任务只能执行一次,请创建新的 Execution")
         self._consumed = True
         self._consumed = True
-        token = bind_trace_id(self.req.trace_id or None)
+        token = bind_trace_id(self.req.trace_id)
+        context_token = bind_log_context(run_id=self.req.run_id, session_id=self.req.session_id,
+                                         user_name=self.req.user_name, kind=self.req.kind)
+        started = time.perf_counter()
+        logger.info("任务开始", extra={"kv": {"event": "execution.started", "model": self.req.model or config.MODEL}})
         stream = None
         stream = None
         try:
         try:
             await asyncio.to_thread(run_store.start, self.req.run_id, self.req.session_id,
             await asyncio.to_thread(run_store.start, self.req.run_id, self.req.session_id,
@@ -213,14 +218,32 @@ class Execution:
             async for frame in self._error_events(exc):
             async for frame in self._error_events(exc):
                 yield frame
                 yield frame
         finally:
         finally:
-            # 清理与发帧独立。ASGI 断连的取消作用域不能打断保存和释放。
-            with anyio.CancelScope(shield=True):
-                cleanup = asyncio.create_task(self._cleanup(stream))
+            cleanup_failed = False
+            try:
+                # 清理与发帧独立。ASGI 断连的取消作用域不能打断保存和释放。
+                with anyio.CancelScope(shield=True):
+                    cleanup = asyncio.create_task(self._cleanup(stream))
+                    try:
+                        await asyncio.shield(cleanup)
+                    except asyncio.CancelledError:
+                        await cleanup
+            except Exception:
+                cleanup_failed = True
+                logger.exception("任务收尾失败", extra={"kv": {"event": "execution.cleanup_failed"}})
+                raise
+            finally:
                 try:
                 try:
-                    await asyncio.shield(cleanup)
-                except asyncio.CancelledError:
-                    await cleanup
-            unbind_trace_id(token)
+                    status = "cleanup_failed" if cleanup_failed else self.status
+                    level = "error" if status in {"failed", "cleanup_failed"} else "warning" if status == "cancelled" else "info"
+                    getattr(logger, level)("任务结束", extra={"kv": {
+                        "event": "execution.completed", "status": status,
+                        "duration_ms": round((time.perf_counter() - started) * 1000, 1),
+                        "input_tokens": self.billing.input_tokens, "output_tokens": self.billing.output_tokens,
+                        "tool_calls": len(self.tool_calls), "error_code": self.error or None,
+                    }})
+                finally:
+                    unbind_log_context(context_token)
+                    unbind_trace_id(token)
 
 
     async def _cleanup(self, stream: Any) -> None:
     async def _cleanup(self, stream: Any) -> None:
         try:
         try:
@@ -285,7 +308,7 @@ class Execution:
                     "冷线程补种历史 session=%s 条数=%d", self.req.session_id, len(messages)
                     "冷线程补种历史 session=%s 条数=%d", self.req.session_id, len(messages)
                 )
                 )
         else:
         else:
-            logger.info("热线程,仅喂当轮消息 session=%s", self.req.session_id)
+            logger.debug("热线程,仅喂当轮消息 session=%s", self.req.session_id)
 
 
         # 用户消息先落库:即使这一轮流到一半挂了,历史里也得有用户问了什么
         # 用户消息先落库:即使这一轮流到一半挂了,历史里也得有用户问了什么
         await asyncio.to_thread(chat_store.save_message, self.req.session_id, "user",
         await asyncio.to_thread(chat_store.save_message, self.req.session_id, "user",
@@ -601,7 +624,7 @@ class Execution:
             except Exception as exc:
             except Exception as exc:
                 self.status = "failed"
                 self.status = "failed"
                 self.error = "message_persistence_failed"
                 self.error = "message_persistence_failed"
-                logger.error("落库失败 session=%s:%s: %s", self.req.session_id, type(exc).__name__, exc)
+                logger.exception("回复落库失败", extra={"kv": {"event": "execution.persistence_failed"}})
 
 
         # 4) 上下文用量 + 压缩存档
         # 4) 上下文用量 + 压缩存档
         await self._persist_context(snapshot)
         await self._persist_context(snapshot)
@@ -704,7 +727,7 @@ class Execution:
                 },
                 },
             )
             )
         except Exception as exc:
         except Exception as exc:
-            logger.error(
+            logger.exception(
                 "上下文用量落库失败 session=%s:%s: %s",
                 "上下文用量落库失败 session=%s:%s: %s",
                 self.req.session_id, type(exc).__name__, exc,
                 self.req.session_id, type(exc).__name__, exc,
             )
             )

+ 17 - 4
runtime/environment.py

@@ -5,6 +5,7 @@ import asyncio
 from contextlib import AsyncExitStack
 from contextlib import AsyncExitStack
 import os
 import os
 import threading
 import threading
+import time
 
 
 import config
 import config
 from agents import agent_memory, agent_registry
 from agents import agent_memory, agent_registry
@@ -40,7 +41,7 @@ async def _stop_billing(stop: asyncio.Event, task: asyncio.Task) -> None:
 
 
 async def _prewarm() -> None:
 async def _prewarm() -> None:
     ok = await agent_registry.prewarm()
     ok = await agent_registry.prewarm()
-    logger.info("agent 预热%s", "完成" if ok else "失败(将在首次请求时重试)")
+    logger.debug("agent 预热%s", "完成" if ok else "失败(将在首次请求时重试)")
 
 
 
 
 async def _join(task: asyncio.Task) -> None:
 async def _join(task: asyncio.Task) -> None:
@@ -83,12 +84,19 @@ class RuntimeEnvironment:
             raise RuntimeError("已有活动的 RuntimeEnvironment,请复用外层环境")
             raise RuntimeError("已有活动的 RuntimeEnvironment,请复用外层环境")
         stack = self._stack = AsyncExitStack()
         stack = self._stack = AsyncExitStack()
         stack.callback(_owner_lock.release)
         stack.callback(_owner_lock.release)
+        started = time.perf_counter()
         try:
         try:
             setup_logging(config.LOG_LEVEL, config.LOG_FORMAT)
             setup_logging(config.LOG_LEVEL, config.LOG_FORMAT)
+            logger.info("运行环境启动", extra={"kv": {"event": "runtime.starting",
+                        "version": config.APP_VERSION, "dialect": config.DB_DIALECT, "model": config.MODEL,
+                        "scheduler": self.scheduler}})
             config.log_config_sources()
             config.log_config_sources()
             _check_single_process()
             _check_single_process()
             catalog = load_extensions()
             catalog = load_extensions()
             approval_names()
             approval_names()
+            logger.info("扩展加载完成", extra={"kv": {"event": "extensions.loaded",
+                        "modules": catalog.modules, "disabled": catalog.disabled_modules,
+                        "tools": len(catalog.tools), "agents": len(catalog.agents)}})
 
 
             stack.callback(db_conn.close)
             stack.callback(db_conn.close)
             db_conn.init_schema()
             db_conn.init_schema()
@@ -129,9 +137,14 @@ class RuntimeEnvironment:
 
 
             if not config.OPENAI_API_KEY:
             if not config.OPENAI_API_KEY:
                 logger.warning("OPENAI_API_KEY 未配置,对话会失败——请在 .env 里设置")
                 logger.warning("OPENAI_API_KEY 未配置,对话会失败——请在 .env 里设置")
-            logger.info("运行环境就绪 scheduler=%s", self.scheduler)
+            logger.info("运行环境就绪", extra={"kv": {"event": "runtime.ready", "scheduler": self.scheduler,
+                        "duration_ms": round((time.perf_counter() - started) * 1000, 1)}})
             return self
             return self
-        except BaseException:
+        except BaseException as exc:
+            if isinstance(exc, asyncio.CancelledError):
+                logger.warning("运行环境启动已取消", extra={"kv": {"event": "runtime.cancelled"}})
+            else:
+                logger.exception("运行环境启动失败", extra={"kv": {"event": "runtime.startup_failed"}})
             await self._close()
             await self._close()
             raise
             raise
 
 
@@ -151,7 +164,7 @@ class RuntimeEnvironment:
             task.result()
             task.result()
         finally:
         finally:
             self._stack = None
             self._stack = None
-        logger.info("运行环境已关闭")
+        logger.info("运行环境已关闭", extra={"kv": {"event": "runtime.closed"}})
         if cancelled:
         if cancelled:
             raise asyncio.CancelledError
             raise asyncio.CancelledError
 
 

+ 18 - 12
runtime/execution.py

@@ -62,17 +62,23 @@ async def execute(*, user_name: str, session_id: str, message: str, agent=None,
 
 
     独立批量任务应各自创建会话;额度与并发限制和聊天入口一致。
     独立批量任务应各自创建会话;额度与并发限制和聊天入口一致。
     """
     """
-    from copy import deepcopy
-    admission = await admit(user_name, session_id)
+    from obs import bind_trace_id, unbind_trace_id, get_trace_id, log_context
+    token = bind_trace_id(trace_id or get_trace_id())
     try:
     try:
-        options = deepcopy(graph_config) if graph_config is not None else {}
-        options.setdefault("configurable", {})["thread_id"] = session_id
-        execution = await build_execution(admission, agent=agent, session_id=session_id,
-            thread_id=session_id, user_name=user_name, message=message, original_user_message=message,
-            kind=kind, config=options, trace_id=trace_id)
-        from runtime.interrupts import pending_interrupts, ResumeError
-        if await pending_interrupts(execution.req.agent, options):
-            raise ResumeError("pending_interrupt", "请先处理当前待确认请求")
-        return await execution.run()
+        with log_context(session_id=session_id, user_name=user_name, kind=kind):
+            from copy import deepcopy
+            admission = await admit(user_name, session_id)
+            try:
+                options = deepcopy(graph_config) if graph_config is not None else {}
+                options.setdefault("configurable", {})["thread_id"] = session_id
+                execution = await build_execution(admission, agent=agent, session_id=session_id,
+                    thread_id=session_id, user_name=user_name, message=message, original_user_message=message,
+                    kind=kind, config=options, trace_id=get_trace_id())
+                from runtime.interrupts import pending_interrupts, ResumeError
+                if await pending_interrupts(execution.req.agent, options):
+                    raise ResumeError("pending_interrupt", "请先处理当前待确认请求")
+                return await execution.run()
+            finally:
+                admission.release()
     finally:
     finally:
-        admission.release()
+        unbind_trace_id(token)

+ 5 - 3
runtime/subagent_tracker.py

@@ -103,7 +103,7 @@ class SubagentTracker:
             call_id = call.get("id") or ""
             call_id = call.get("id") or ""
             if not call_id:
             if not call_id:
                 # 没有 id 就无法把后续子图事件关联回来,跳过而不是编一个
                 # 没有 id 就无法把后续子图事件关联回来,跳过而不是编一个
-                logger.debug("task 调用缺少 id,跳过跟踪:%s", call)
+                logger.debug("task 调用缺少 id,跳过跟踪")
                 continue
                 continue
             args = call.get("args") or {}
             args = call.get("args") or {}
             if not isinstance(args, dict):
             if not isinstance(args, dict):
@@ -122,8 +122,8 @@ class SubagentTracker:
                 self._order.append(call_id)
                 self._order.append(call_id)
                 touched.append(run)
                 touched.append(run)
                 logger.info(
                 logger.info(
-                    "子智能体启动 id=%s type=%s desc=%s",
-                    call_id[:8], subagent_type, description[:60],
+                    "子智能体启动", extra={"kv": {"event": "subagent.started",
+                    "tool_call_id": call_id, "agent": subagent_type}},
                 )
                 )
             elif existing.status == "started":
             elif existing.status == "started":
                 existing.status = "running"
                 existing.status = "running"
@@ -156,6 +156,8 @@ class SubagentTracker:
         logger.info(
         logger.info(
             "子智能体结束 id=%s type=%s status=%s 耗时=%sms 工具调用=%d",
             "子智能体结束 id=%s type=%s status=%s 耗时=%sms 工具调用=%d",
             run.call_id[:8], run.subagent_type, run.status, run.duration_ms, run.tool_calls,
             run.call_id[:8], run.subagent_type, run.status, run.duration_ms, run.tool_calls,
+            extra={"kv": {"event": "subagent.completed", "tool_call_id": run.call_id,
+                          "agent": run.subagent_type, "status": run.status, "duration_ms": run.duration_ms}},
         )
         )
         return run
         return run
 
 

+ 31 - 2
scheduler/runner.py

@@ -12,12 +12,15 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 import asyncio
 import asyncio
+import logging
+import time
 from typing import Any
 from typing import Any
 
 
 import config
 import config
-from billing import pricing
+from billing import pricing, guard
+from runtime import concurrency
 from db import chat_store, db_conn, schedule_store
 from db import chat_store, db_conn, schedule_store
-from obs import get_logger
+from obs import get_logger, get_trace_id, bind_trace_id, unbind_trace_id, log_context
 
 
 logger = get_logger(__name__)
 logger = get_logger(__name__)
 
 
@@ -27,6 +30,32 @@ SCHEDULED_RECURSION_LIMIT = 30
 
 
 
 
 async def run_schedule(schedule_id: str, trigger: str = "cron") -> dict[str, Any]:
 async def run_schedule(schedule_id: str, trigger: str = "cron") -> dict[str, Any]:
+    token = bind_trace_id(get_trace_id())
+    started = time.perf_counter()
+    try:
+        with log_context(schedule_id=schedule_id, trigger=trigger):
+            try:
+                result = await _run_schedule(schedule_id, trigger)
+            except asyncio.CancelledError:
+                logger.warning("定时执行已取消", extra={"kv": {"event": "schedule.cancelled"}})
+                raise
+            except (guard.QuotaExceededError, concurrency.TooManyTasksError, concurrency.SessionBusyError) as exc:
+                logger.warning("定时执行未获准", extra={"kv": {"event": "schedule.rejected", "error_type": type(exc).__name__}})
+                raise
+            except Exception:
+                logger.exception("定时执行失败", extra={"kv": {"event": "schedule.failed"}})
+                raise
+            logger.log(logging.INFO if result.get("ok") else logging.WARNING, "定时执行结束", extra={"kv": {
+                "event": "schedule.completed", "status": result.get("status", result.get("error")),
+                "schedule_run_id": result.get("run_id"), "session_id": result.get("session_id"),
+                "duration_ms": round((time.perf_counter() - started) * 1000, 1),
+            }})
+            return result
+    finally:
+        unbind_trace_id(token)
+
+
+async def _run_schedule(schedule_id: str, trigger: str) -> dict[str, Any]:
     """执行并持久化状态;准入失败由 HTTP 或调度回调处理。"""
     """执行并持久化状态;准入失败由 HTTP 或调度回调处理。"""
     schedule = await asyncio.to_thread(schedule_store.get_schedule, schedule_id)
     schedule = await asyncio.to_thread(schedule_store.get_schedule, schedule_id)
     if schedule is None:
     if schedule is None:

+ 2 - 2
scheduler/scheduler.py

@@ -42,8 +42,8 @@ async def _invoke(schedule_id: str) -> None:
     _active.add(task)
     _active.add(task)
     try:
     try:
         await run_schedule(schedule_id, trigger="cron")
         await run_schedule(schedule_id, trigger="cron")
-    except Exception:  # pragma: no cover - run_schedule 内部已兜底
-        logger.exception("定时任务回调异常 id=%s", schedule_id)
+    except Exception:  # runner 已记录带 schedule_id / trace_id 的错误,避免重复堆栈
+        pass
     finally:
     finally:
         _active.discard(task)
         _active.discard(task)
 
 

+ 1 - 1
scripts/selfcheck.py

@@ -49,7 +49,7 @@ MODULES = [
     "api.source_tracker", "api.concurrency", "api.sse_core", "api.models",
     "api.source_tracker", "api.concurrency", "api.sse_core", "api.models",
     "runtime.environment", "runtime.engine", "runtime.execution", "runtime.events", "runtime.interrupts",
     "runtime.environment", "runtime.engine", "runtime.execution", "runtime.events", "runtime.interrupts",
     "runtime.concurrency", "runtime.context_meter", "runtime.source_tracker", "runtime.subagent_tracker",
     "runtime.concurrency", "runtime.context_meter", "runtime.source_tracker", "runtime.subagent_tracker",
-    "api.chat_routes", "api.session_routes", "api.model_routes",
+    "api.request_logging", "api.chat_routes", "api.session_routes", "api.model_routes",
     "api.usage_routes", "api.schedule_routes", "api",
     "api.usage_routes", "api.schedule_routes", "api",
     "scheduler.runner", "scheduler.scheduler", "main",
     "scheduler.runner", "scheduler.scheduler", "main",
 ]
 ]

+ 284 - 0
tests/test_logging.py

@@ -0,0 +1,284 @@
+"""日志格式、关联信息、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()