mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
feat: propagate request correlation context
This commit is contained in:
@@ -17,6 +17,8 @@ from requests import Response, Session
|
|||||||
from urllib3.exceptions import InsecureRequestWarning
|
from urllib3.exceptions import InsecureRequestWarning
|
||||||
from urllib.parse import unquote, quote
|
from urllib.parse import unquote, quote
|
||||||
|
|
||||||
|
from app.runtime.correlation import with_correlation_header
|
||||||
|
|
||||||
|
|
||||||
urllib3.disable_warnings(InsecureRequestWarning)
|
urllib3.disable_warnings(InsecureRequestWarning)
|
||||||
|
|
||||||
@@ -418,7 +420,9 @@ class RequestUtils:
|
|||||||
req_method = requests.request
|
req_method = requests.request
|
||||||
else:
|
else:
|
||||||
req_method = self._session.request
|
req_method = self._session.request
|
||||||
kwargs.setdefault("headers", self._headers)
|
kwargs["headers"] = with_correlation_header(
|
||||||
|
kwargs.get("headers", self._headers)
|
||||||
|
)
|
||||||
kwargs.setdefault("cookies", self._cookies)
|
kwargs.setdefault("cookies", self._cookies)
|
||||||
kwargs.setdefault("proxies", self._proxies)
|
kwargs.setdefault("proxies", self._proxies)
|
||||||
kwargs.setdefault("timeout", self._timeout)
|
kwargs.setdefault("timeout", self._timeout)
|
||||||
@@ -1195,7 +1199,9 @@ class AsyncRequestUtils:
|
|||||||
"""
|
"""
|
||||||
执行实际的异步请求
|
执行实际的异步请求
|
||||||
"""
|
"""
|
||||||
kwargs.setdefault("headers", self._headers)
|
kwargs["headers"] = with_correlation_header(
|
||||||
|
kwargs.get("headers", self._headers)
|
||||||
|
)
|
||||||
# 共享池下 client 自带默认 timeout,这里用每请求 timeout 覆盖以尊重实例配置
|
# 共享池下 client 自带默认 timeout,这里用每请求 timeout 覆盖以尊重实例配置
|
||||||
kwargs.setdefault("timeout", self._timeout)
|
kwargs.setdefault("timeout", self._timeout)
|
||||||
# Cookie 在 request() 入口已按 path 处理:
|
# Cookie 在 request() 入口已按 path 处理:
|
||||||
@@ -1329,7 +1335,9 @@ class AsyncRequestUtils:
|
|||||||
:return: 上下文管理器,进入后 yield httpx.Response(出错时 yield None)
|
:return: 上下文管理器,进入后 yield httpx.Response(出错时 yield None)
|
||||||
"""
|
"""
|
||||||
cookies_dict: Optional[dict] = self._cookies if isinstance(self._cookies, dict) else None
|
cookies_dict: Optional[dict] = self._cookies if isinstance(self._cookies, dict) else None
|
||||||
kwargs.setdefault("headers", self._headers)
|
kwargs["headers"] = with_correlation_header(
|
||||||
|
kwargs.get("headers", self._headers)
|
||||||
|
)
|
||||||
|
|
||||||
# 与 _make_request 保持一致:复用 keep-alive 时偶遇对端 FIN 的连接,
|
# 与 _make_request 保持一致:复用 keep-alive 时偶遇对端 FIN 的连接,
|
||||||
# 流式 GET 是幂等的,单次重试即可
|
# 流式 GET 是幂等的,单次重试即可
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""HTTP 请求关联 ID 的 ASGI 适配器。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.runtime.correlation import (
|
||||||
|
CORRELATION_ID_HEADER,
|
||||||
|
correlation_scope,
|
||||||
|
normalize_correlation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CorrelationIdMiddleware:
|
||||||
|
"""验证入口 ID、绑定请求上下文并把同一 ID 写回响应。"""
|
||||||
|
|
||||||
|
def __init__(self, app: Any) -> None:
|
||||||
|
"""保存下游 ASGI 应用。"""
|
||||||
|
self._app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
|
||||||
|
"""只治理 HTTP scope,并让绑定覆盖完整流式响应生命周期。"""
|
||||||
|
if scope.get("type") != "http":
|
||||||
|
await self._app(scope, receive, send)
|
||||||
|
return
|
||||||
|
raw_headers = dict(scope.get("headers") or [])
|
||||||
|
candidate = raw_headers.get(CORRELATION_ID_HEADER.lower().encode("ascii"))
|
||||||
|
correlation_id = normalize_correlation_id(
|
||||||
|
candidate.decode("ascii", errors="ignore") if candidate else None
|
||||||
|
)
|
||||||
|
scope.setdefault("state", {})["request_id"] = correlation_id
|
||||||
|
|
||||||
|
async def send_with_correlation(message: dict) -> None:
|
||||||
|
"""在响应开始帧中覆盖为当前请求的安全关联 ID。"""
|
||||||
|
if message.get("type") == "http.response.start":
|
||||||
|
headers = list(message.get("headers") or [])
|
||||||
|
header_name = CORRELATION_ID_HEADER.lower().encode("ascii")
|
||||||
|
headers = [item for item in headers if item[0].lower() != header_name]
|
||||||
|
headers.append((header_name, correlation_id.encode("ascii")))
|
||||||
|
message["headers"] = headers
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
with correlation_scope(correlation_id):
|
||||||
|
await self._app(scope, receive, send_with_correlation)
|
||||||
+5
-1
@@ -8,6 +8,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
from app.api.response import ResponseAPIRoute
|
from app.api.response import ResponseAPIRoute
|
||||||
|
from app.adapters.web.correlation import CorrelationIdMiddleware
|
||||||
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
|
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
|
||||||
from app.adapters.web.health import install_health_routes
|
from app.adapters.web.health import install_health_routes
|
||||||
from app.application.plugin.routes import configure_plugin_routes
|
from app.application.plugin.routes import configure_plugin_routes
|
||||||
@@ -19,8 +20,9 @@ from app.adapters.web.security.access import (
|
|||||||
from app.application.security.token import create_access_token, decode_access_token
|
from app.application.security.token import create_access_token, decode_access_token
|
||||||
from app.runtime.extensions.plugin_manager import PluginManager
|
from app.runtime.extensions.plugin_manager import PluginManager
|
||||||
from app.runtime.config import settings
|
from app.runtime.config import settings
|
||||||
|
from app.runtime.correlation import get_correlation_id
|
||||||
from app.runtime.localization import LocaleHelper
|
from app.runtime.localization import LocaleHelper
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import configure_correlation_id_provider, logger
|
||||||
from app.schemas.openai import (
|
from app.schemas.openai import (
|
||||||
AnthropicErrorDetail,
|
AnthropicErrorDetail,
|
||||||
AnthropicErrorResponse,
|
AnthropicErrorResponse,
|
||||||
@@ -291,6 +293,7 @@ def create_app() -> FastAPI:
|
|||||||
"""
|
"""
|
||||||
创建并配置 FastAPI 应用实例。
|
创建并配置 FastAPI 应用实例。
|
||||||
"""
|
"""
|
||||||
|
configure_correlation_id_provider(get_correlation_id)
|
||||||
_app = FastAPI(
|
_app = FastAPI(
|
||||||
title=settings.PROJECT_NAME,
|
title=settings.PROJECT_NAME,
|
||||||
version=APP_VERSION,
|
version=APP_VERSION,
|
||||||
@@ -317,6 +320,7 @@ def create_app() -> FastAPI:
|
|||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
_app.add_middleware(CorrelationIdMiddleware)
|
||||||
|
|
||||||
@_app.middleware("http")
|
@_app.middleware("http")
|
||||||
async def locale_context_middleware(
|
async def locale_context_middleware(
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""请求与后台工作共用的关联 ID 上下文。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from contextvars import ContextVar, Token
|
||||||
|
from typing import Any, Callable, Iterator, Mapping
|
||||||
|
|
||||||
|
CORRELATION_ID_HEADER = "X-Request-ID"
|
||||||
|
MAX_CORRELATION_ID_LENGTH = 64
|
||||||
|
_VALID_CORRELATION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$")
|
||||||
|
_CURRENT_CORRELATION_ID: ContextVar[str | None] = ContextVar(
|
||||||
|
"moviepilot_correlation_id",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_correlation_id(candidate: str | None) -> str:
|
||||||
|
"""接受安全的调用方 ID;非法、超长或缺失值均替换为随机 ID。"""
|
||||||
|
if candidate and _VALID_CORRELATION_ID.fullmatch(candidate):
|
||||||
|
return candidate
|
||||||
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
|
def get_correlation_id(default: str | None = None) -> str | None:
|
||||||
|
"""返回当前执行上下文中的关联 ID。"""
|
||||||
|
return _CURRENT_CORRELATION_ID.get() or default
|
||||||
|
|
||||||
|
|
||||||
|
def set_correlation_id(correlation_id: str) -> Token[str | None]:
|
||||||
|
"""设置当前关联 ID,并返回供调用方精确恢复的 token。"""
|
||||||
|
return _CURRENT_CORRELATION_ID.set(correlation_id)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_correlation_id(token: Token[str | None]) -> None:
|
||||||
|
"""恢复设置关联 ID 之前的上下文。"""
|
||||||
|
_CURRENT_CORRELATION_ID.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def correlation_scope(correlation_id: str | None) -> Iterator[str | None]:
|
||||||
|
"""在当前同步或异步任务作用域内绑定并自动恢复关联 ID。"""
|
||||||
|
if correlation_id is None:
|
||||||
|
yield None
|
||||||
|
return
|
||||||
|
token = set_correlation_id(correlation_id)
|
||||||
|
try:
|
||||||
|
yield correlation_id
|
||||||
|
finally:
|
||||||
|
reset_correlation_id(token)
|
||||||
|
|
||||||
|
|
||||||
|
def with_correlation_header(headers: Mapping[str, str] | None) -> dict[str, str]:
|
||||||
|
"""复制请求头并在调用方未显式指定时加入当前关联 ID。"""
|
||||||
|
result = dict(headers or {})
|
||||||
|
if any(key.lower() == CORRELATION_ID_HEADER.lower() for key in result):
|
||||||
|
return result
|
||||||
|
correlation_id = get_correlation_id()
|
||||||
|
if correlation_id:
|
||||||
|
result[CORRELATION_ID_HEADER] = correlation_id
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def call_with_correlation(
|
||||||
|
correlation_id: str | None,
|
||||||
|
func: Callable[..., Any],
|
||||||
|
args: tuple[Any, ...],
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
) -> Any:
|
||||||
|
"""从可序列化参数恢复关联 ID 后调用函数,供子进程入口使用。"""
|
||||||
|
with correlation_scope(correlation_id):
|
||||||
|
return func(*args, **kwargs)
|
||||||
@@ -12,6 +12,7 @@ from app.runtime.event.binding import EventBindingResolver
|
|||||||
from app.runtime.event.registry import EventRegistry
|
from app.runtime.event.registry import EventRegistry
|
||||||
from app.runtime.execution import run_in_threadpool
|
from app.runtime.execution import run_in_threadpool
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
|
from app.runtime.correlation import correlation_scope
|
||||||
from app.schemas.types import EventType
|
from app.schemas.types import EventType
|
||||||
|
|
||||||
|
|
||||||
@@ -114,6 +115,7 @@ class EventDispatcher:
|
|||||||
event_type=event.event_type,
|
event_type=event.event_type,
|
||||||
event_data=event_data,
|
event_data=event_data,
|
||||||
priority=event.priority,
|
priority=event.priority,
|
||||||
|
correlation_id=event.correlation_id,
|
||||||
)
|
)
|
||||||
if inspect.iscoroutinefunction(handler):
|
if inspect.iscoroutinefunction(handler):
|
||||||
asyncio.run_coroutine_threadsafe(
|
asyncio.run_coroutine_threadsafe(
|
||||||
@@ -143,16 +145,17 @@ class EventDispatcher:
|
|||||||
if not resolved:
|
if not resolved:
|
||||||
return
|
return
|
||||||
method, binding, class_name, method_name = resolved
|
method, binding, class_name, method_name = resolved
|
||||||
try:
|
with correlation_scope(event.correlation_id):
|
||||||
method(event)
|
try:
|
||||||
except Exception as err:
|
method(event)
|
||||||
self._error_handler(
|
except Exception as err:
|
||||||
event=event,
|
self._error_handler(
|
||||||
module_name=binding.owner_name,
|
event=event,
|
||||||
class_name=class_name,
|
module_name=binding.owner_name,
|
||||||
method_name=method_name,
|
class_name=class_name,
|
||||||
e=err,
|
method_name=method_name,
|
||||||
)
|
e=err,
|
||||||
|
)
|
||||||
|
|
||||||
async def invoke_async(self, handler: Callable, event: Any) -> None:
|
async def invoke_async(self, handler: Callable, event: Any) -> None:
|
||||||
"""解析实例绑定,并按处理器类型选择协程、线程池或同步调用。"""
|
"""解析实例绑定,并按处理器类型选择协程、线程池或同步调用。"""
|
||||||
@@ -160,21 +163,22 @@ class EventDispatcher:
|
|||||||
if not resolved:
|
if not resolved:
|
||||||
return
|
return
|
||||||
method, binding, class_name, method_name = resolved
|
method, binding, class_name, method_name = resolved
|
||||||
try:
|
with correlation_scope(event.correlation_id):
|
||||||
if inspect.iscoroutinefunction(method):
|
try:
|
||||||
await method(event)
|
if inspect.iscoroutinefunction(method):
|
||||||
elif binding.run_sync_in_threadpool or not class_name:
|
await method(event)
|
||||||
await run_in_threadpool(method, event)
|
elif binding.run_sync_in_threadpool or not class_name:
|
||||||
else:
|
await run_in_threadpool(method, event)
|
||||||
method(event)
|
else:
|
||||||
except Exception as err:
|
method(event)
|
||||||
self._error_handler(
|
except Exception as err:
|
||||||
event=event,
|
self._error_handler(
|
||||||
module_name=binding.owner_name,
|
event=event,
|
||||||
class_name=class_name,
|
module_name=binding.owner_name,
|
||||||
method_name=method_name,
|
class_name=class_name,
|
||||||
e=err,
|
method_name=method_name,
|
||||||
)
|
e=err,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def should_dispatch_to_target_plugin(
|
def should_dispatch_to_target_plugin(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.runtime.event.dispatch import EventDispatcher
|
|||||||
from app.runtime.event.errors import EventErrorNotifier, EventErrorPolicy
|
from app.runtime.event.errors import EventErrorNotifier, EventErrorPolicy
|
||||||
from app.runtime.event.registry import EventRegistry
|
from app.runtime.event.registry import EventRegistry
|
||||||
from app.runtime.event.contracts import validate_event_payload
|
from app.runtime.event.contracts import validate_event_payload
|
||||||
|
from app.runtime.correlation import get_correlation_id
|
||||||
|
|
||||||
DEFAULT_EVENT_PRIORITY = 10 # 事件的默认优先级
|
DEFAULT_EVENT_PRIORITY = 10 # 事件的默认优先级
|
||||||
MIN_EVENT_CONSUMER_THREADS = 1 # 最小事件消费者线程数
|
MIN_EVENT_CONSUMER_THREADS = 1 # 最小事件消费者线程数
|
||||||
@@ -35,11 +36,13 @@ class Event:
|
|||||||
|
|
||||||
def __init__(self, event_type: Union[EventType, ChainEventType],
|
def __init__(self, event_type: Union[EventType, ChainEventType],
|
||||||
event_data: Optional[Union[Dict, ChainEventData]] = None,
|
event_data: Optional[Union[Dict, ChainEventData]] = None,
|
||||||
priority: Optional[int] = DEFAULT_EVENT_PRIORITY):
|
priority: Optional[int] = DEFAULT_EVENT_PRIORITY,
|
||||||
|
correlation_id: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
:param event_type: 事件的类型,支持 EventType 或 ChainEventType
|
:param event_type: 事件的类型,支持 EventType 或 ChainEventType
|
||||||
:param event_data: 可选,事件携带的数据,默认为空字典
|
:param event_data: 可选,事件携带的数据,默认为空字典
|
||||||
:param priority: 可选,事件的优先级,默认为 10
|
:param priority: 可选,事件的优先级,默认为 10
|
||||||
|
:param correlation_id: 生产事件时固化的请求关联 ID
|
||||||
"""
|
"""
|
||||||
payload_problems = validate_event_payload(event_type, event_data)
|
payload_problems = validate_event_payload(event_type, event_data)
|
||||||
if payload_problems:
|
if payload_problems:
|
||||||
@@ -52,6 +55,7 @@ class Event:
|
|||||||
self.event_type = event_type # 事件类型
|
self.event_type = event_type # 事件类型
|
||||||
self.event_data = event_data or {} # 事件数据
|
self.event_data = event_data or {} # 事件数据
|
||||||
self.priority = priority # 事件优先级
|
self.priority = priority # 事件优先级
|
||||||
|
self.correlation_id = correlation_id or get_correlation_id()
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
import inspect
|
||||||
import time
|
import time
|
||||||
|
from contextvars import copy_context
|
||||||
from functools import partial, wraps
|
from functools import partial, wraps
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
@@ -16,7 +17,8 @@ async def run_in_threadpool(
|
|||||||
"""在线程中执行同步函数,保持 FastAPI 旧帮助函数的参数语义。"""
|
"""在线程中执行同步函数,保持 FastAPI 旧帮助函数的参数语义。"""
|
||||||
if kwargs:
|
if kwargs:
|
||||||
func = partial(func, **kwargs)
|
func = partial(func, **kwargs)
|
||||||
return await run_sync(func, *args)
|
context = copy_context()
|
||||||
|
return await run_sync(context.run, func, *args)
|
||||||
|
|
||||||
|
|
||||||
def retry(ExceptionToCheck: Any,
|
def retry(ExceptionToCheck: Any,
|
||||||
|
|||||||
+24
-5
@@ -12,12 +12,11 @@ from concurrent.futures import ThreadPoolExecutor
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, Protocol
|
from typing import Any, Callable, Dict, Optional, Protocol
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
class LogConfigModel(BaseModel):
|
class LogConfigModel(BaseModel):
|
||||||
"""描述日志级别、格式和文件写入策略。"""
|
"""描述日志级别、格式和文件写入策略。"""
|
||||||
|
|
||||||
@@ -28,8 +27,12 @@ class LogConfigModel(BaseModel):
|
|||||||
LOG_LEVEL: str = "INFO"
|
LOG_LEVEL: str = "INFO"
|
||||||
LOG_MAX_FILE_SIZE: int = 5
|
LOG_MAX_FILE_SIZE: int = 5
|
||||||
LOG_BACKUP_COUNT: int = 10
|
LOG_BACKUP_COUNT: int = 10
|
||||||
LOG_CONSOLE_FORMAT: str = "%(leveltext)s[%(name)s] %(asctime)s %(message)s"
|
LOG_CONSOLE_FORMAT: str = (
|
||||||
LOG_FILE_FORMAT: str = "【%(levelname)s】%(asctime)s - %(message)s"
|
"%(leveltext)s[%(name)s] %(asctime)s [%(correlation_id)s] %(message)s"
|
||||||
|
)
|
||||||
|
LOG_FILE_FORMAT: str = (
|
||||||
|
"【%(levelname)s】%(asctime)s [%(correlation_id)s] - %(message)s"
|
||||||
|
)
|
||||||
ASYNC_FILE_QUEUE_SIZE: int = 1000
|
ASYNC_FILE_QUEUE_SIZE: int = 1000
|
||||||
ASYNC_FILE_WORKERS: int = 2
|
ASYNC_FILE_WORKERS: int = 2
|
||||||
BATCH_WRITE_SIZE: int = 50
|
BATCH_WRITE_SIZE: int = 50
|
||||||
@@ -60,6 +63,7 @@ class LogEntry:
|
|||||||
self.message = message
|
self.message = message
|
||||||
self.file_path = file_path
|
self.file_path = file_path
|
||||||
self.timestamp = timestamp or datetime.now()
|
self.timestamp = timestamp or datetime.now()
|
||||||
|
self.correlation_id = _get_log_correlation_id()
|
||||||
|
|
||||||
|
|
||||||
class LogWriter(Protocol):
|
class LogWriter(Protocol):
|
||||||
@@ -73,6 +77,18 @@ class LogWriter(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
log_settings = LogSettings()
|
log_settings = LogSettings()
|
||||||
|
_correlation_id_provider: Callable[[], str | None] = lambda: None
|
||||||
|
|
||||||
|
|
||||||
|
def configure_correlation_id_provider(provider: Callable[[], str | None]) -> None:
|
||||||
|
"""由组合根注入日志关联 ID 读取端口,保持日志模块为依赖叶节点。"""
|
||||||
|
global _correlation_id_provider
|
||||||
|
_correlation_id_provider = provider
|
||||||
|
|
||||||
|
|
||||||
|
def _get_log_correlation_id() -> str:
|
||||||
|
"""读取当前关联 ID;未装配或无请求上下文时返回稳定占位符。"""
|
||||||
|
return _correlation_id_provider() or "-"
|
||||||
|
|
||||||
|
|
||||||
class NonBlockingFileHandler:
|
class NonBlockingFileHandler:
|
||||||
@@ -169,7 +185,7 @@ class NonBlockingFileHandler:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _to_record(entry: LogEntry) -> logging.LogRecord:
|
def _to_record(entry: LogEntry) -> logging.LogRecord:
|
||||||
"""把日志条目转换为标准库日志记录。"""
|
"""把日志条目转换为标准库日志记录。"""
|
||||||
return logging.LogRecord(
|
record = logging.LogRecord(
|
||||||
name="",
|
name="",
|
||||||
level=getattr(logging, entry.level.upper(), logging.INFO),
|
level=getattr(logging, entry.level.upper(), logging.INFO),
|
||||||
pathname="",
|
pathname="",
|
||||||
@@ -179,6 +195,8 @@ class NonBlockingFileHandler:
|
|||||||
exc_info=None,
|
exc_info=None,
|
||||||
created=entry.timestamp.timestamp(),
|
created=entry.timestamp.timestamp(),
|
||||||
)
|
)
|
||||||
|
record.correlation_id = entry.correlation_id
|
||||||
|
return record
|
||||||
|
|
||||||
def _batch_writer(self) -> None:
|
def _batch_writer(self) -> None:
|
||||||
"""持续收集队列日志,并在停止哨兵后排空已有批次。"""
|
"""持续收集队列日志,并在停止哨兵后排空已有批次。"""
|
||||||
@@ -258,6 +276,7 @@ class CustomFormatter(logging.Formatter):
|
|||||||
separator = " " * max(8 - len(record.levelname), 0)
|
separator = " " * max(8 - len(record.levelname), 0)
|
||||||
colorizer = _LEVEL_NAME_COLORS.get(record.levelno, str)
|
colorizer = _LEVEL_NAME_COLORS.get(record.levelno, str)
|
||||||
record.leveltext = colorizer(record.levelname + ":") + separator
|
record.leveltext = colorizer(record.levelname + ":") + separator
|
||||||
|
record.correlation_id = _get_log_correlation_id()
|
||||||
return super().format(record)
|
return super().format(record)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from contextvars import copy_context
|
||||||
|
|
||||||
from app.runtime.config import settings
|
from app.runtime.config import settings
|
||||||
from app.foundation.singleton import Singleton
|
from app.foundation.singleton import Singleton
|
||||||
@@ -20,7 +21,8 @@ class ThreadHelper(metaclass=Singleton):
|
|||||||
:param kwargs: 参数
|
:param kwargs: 参数
|
||||||
:return: future
|
:return: future
|
||||||
"""
|
"""
|
||||||
return self.pool.submit(func, *args, **kwargs)
|
context = copy_context()
|
||||||
|
return self.pool.submit(context.run, func, *args, **kwargs)
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
+5
-1
@@ -45,6 +45,7 @@ from app.runtime.gc import get_memory_usage
|
|||||||
from app.runtime.reload import ConfigReloadMixin
|
from app.runtime.reload import ConfigReloadMixin
|
||||||
from app.foundation.singleton import SingletonClass
|
from app.foundation.singleton import SingletonClass
|
||||||
from app.runtime.scheduling import TimerUtils
|
from app.runtime.scheduling import TimerUtils
|
||||||
|
from app.runtime.correlation import call_with_correlation, get_correlation_id
|
||||||
|
|
||||||
lock = threading.Lock()
|
lock = threading.Lock()
|
||||||
SCHEDULER_PROGRESS_PREFIX = "scheduler"
|
SCHEDULER_PROGRESS_PREFIX = "scheduler"
|
||||||
@@ -856,7 +857,10 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
|
|||||||
deferred_finish = __start_coro(func(*args, **kwargs))
|
deferred_finish = __start_coro(func(*args, **kwargs))
|
||||||
elif run_in_process:
|
elif run_in_process:
|
||||||
# 多进程运行
|
# 多进程运行
|
||||||
p = multiprocessing.Process(target=func, args=args, kwargs=kwargs)
|
p = multiprocessing.Process(
|
||||||
|
target=call_with_correlation,
|
||||||
|
args=(get_correlation_id(), func, args, kwargs),
|
||||||
|
)
|
||||||
p.start()
|
p.start()
|
||||||
p.join()
|
p.join()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -731,6 +731,17 @@ app/scheduler.py # APScheduler 兼容 Facade
|
|||||||
4. 日志 formatter 增加结构字段,不在消息字符串中到处手拼。
|
4. 日志 formatter 增加结构字段,不在消息字符串中到处手拼。
|
||||||
5. 外部请求可传标准 trace headers 或项目 correlation header,但不得泄露用户 token。
|
5. 外部请求可传标准 trace headers 或项目 correlation header,但不得泄露用户 token。
|
||||||
|
|
||||||
|
**实施记录(2026-08-21)**:
|
||||||
|
|
||||||
|
- 新增受 64 字符安全字符集约束的 `moviepilot_correlation_id` ContextVar 和纯 ASGI middleware;合法
|
||||||
|
`X-Request-ID` 原样使用,非法值重新生成,`request.state`、普通响应和 SSE 握手响应回写同一个 ID。
|
||||||
|
- 平台日志 formatter 以独立 `correlation_id` 字段输出;`app.runtime.execution`、共享 `ThreadHelper`、
|
||||||
|
Event 生产/消费均显式复制或恢复上下文。Event 在生产时固化 ID,广播线程不能用自己的空上下文覆盖它。
|
||||||
|
- Scheduler 多进程入口把关联 ID 作为显式可序列化参数传入,不依赖 fork 继承;`RequestUtils` 和
|
||||||
|
`AsyncRequestUtils` 在调用方未指定时传播 `X-Request-ID`,不读取或复制任何鉴权 token。
|
||||||
|
- 并发请求、非法头、线程池、事件处理、SSE、同步/异步外呼和显式外呼头覆盖均有专项测试;原 API
|
||||||
|
响应、健康探针、日志和搜索流式测试保持通过。
|
||||||
|
|
||||||
#### ARCH-261:指标与可选 OpenTelemetry Adapter
|
#### ARCH-261:指标与可选 OpenTelemetry Adapter
|
||||||
|
|
||||||
先定义内部观测端口和低基数指标:
|
先定义内部观测端口和低基数指标:
|
||||||
|
|||||||
+14
-3
@@ -13,8 +13,8 @@
|
|||||||
"runtime_to_db": [],
|
"runtime_to_db": [],
|
||||||
"workflow_to_db": []
|
"workflow_to_db": []
|
||||||
},
|
},
|
||||||
"edge_count": 6223,
|
"edge_count": 6232,
|
||||||
"edge_sha256": "f64e5083ab697022127780800617474d12f10b05a6fea26787e751a3f8a00421",
|
"edge_sha256": "2139dcdcfc12b4f29c732471744d09b0990c51867d4ad227026b00fa947c90d8",
|
||||||
"edges": [
|
"edges": [
|
||||||
"app -> app.runtime",
|
"app -> app.runtime",
|
||||||
"app -> app.runtime.compat",
|
"app -> app.runtime.compat",
|
||||||
@@ -104,6 +104,8 @@
|
|||||||
"app.adapters.network.doh -> app.runtime.config",
|
"app.adapters.network.doh -> app.runtime.config",
|
||||||
"app.adapters.network.doh -> app.runtime.log",
|
"app.adapters.network.doh -> app.runtime.log",
|
||||||
"app.adapters.network.doh -> app.runtime.reload",
|
"app.adapters.network.doh -> app.runtime.reload",
|
||||||
|
"app.adapters.network.http -> app.runtime",
|
||||||
|
"app.adapters.network.http -> app.runtime.correlation",
|
||||||
"app.adapters.system.display -> app.foundation",
|
"app.adapters.system.display -> app.foundation",
|
||||||
"app.adapters.system.display -> app.foundation.singleton",
|
"app.adapters.system.display -> app.foundation.singleton",
|
||||||
"app.adapters.system.display -> app.runtime",
|
"app.adapters.system.display -> app.runtime",
|
||||||
@@ -151,6 +153,8 @@
|
|||||||
"app.adapters.system.rust -> app.runtime",
|
"app.adapters.system.rust -> app.runtime",
|
||||||
"app.adapters.system.rust -> app.runtime.config",
|
"app.adapters.system.rust -> app.runtime.config",
|
||||||
"app.adapters.system.rust -> app.runtime.log",
|
"app.adapters.system.rust -> app.runtime.log",
|
||||||
|
"app.adapters.web.correlation -> app.runtime",
|
||||||
|
"app.adapters.web.correlation -> app.runtime.correlation",
|
||||||
"app.adapters.web.health -> app.runtime",
|
"app.adapters.web.health -> app.runtime",
|
||||||
"app.adapters.web.health -> app.runtime.health",
|
"app.adapters.web.health -> app.runtime.health",
|
||||||
"app.adapters.web.security.access -> app.runtime",
|
"app.adapters.web.security.access -> app.runtime",
|
||||||
@@ -3719,6 +3723,7 @@
|
|||||||
"app.domain.title -> app.schemas.types",
|
"app.domain.title -> app.schemas.types",
|
||||||
"app.factory -> app.adapters",
|
"app.factory -> app.adapters",
|
||||||
"app.factory -> app.adapters.web",
|
"app.factory -> app.adapters.web",
|
||||||
|
"app.factory -> app.adapters.web.correlation",
|
||||||
"app.factory -> app.adapters.web.health",
|
"app.factory -> app.adapters.web.health",
|
||||||
"app.factory -> app.adapters.web.plugin",
|
"app.factory -> app.adapters.web.plugin",
|
||||||
"app.factory -> app.adapters.web.plugin.routes",
|
"app.factory -> app.adapters.web.plugin.routes",
|
||||||
@@ -3733,6 +3738,7 @@
|
|||||||
"app.factory -> app.application.security.token",
|
"app.factory -> app.application.security.token",
|
||||||
"app.factory -> app.runtime",
|
"app.factory -> app.runtime",
|
||||||
"app.factory -> app.runtime.config",
|
"app.factory -> app.runtime.config",
|
||||||
|
"app.factory -> app.runtime.correlation",
|
||||||
"app.factory -> app.runtime.extensions",
|
"app.factory -> app.runtime.extensions",
|
||||||
"app.factory -> app.runtime.extensions.plugin_manager",
|
"app.factory -> app.runtime.extensions.plugin_manager",
|
||||||
"app.factory -> app.runtime.localization",
|
"app.factory -> app.runtime.localization",
|
||||||
@@ -5353,6 +5359,7 @@
|
|||||||
"app.runtime.event.contracts -> app.schemas.event",
|
"app.runtime.event.contracts -> app.schemas.event",
|
||||||
"app.runtime.event.contracts -> app.schemas.types",
|
"app.runtime.event.contracts -> app.schemas.types",
|
||||||
"app.runtime.event.dispatch -> app.runtime",
|
"app.runtime.event.dispatch -> app.runtime",
|
||||||
|
"app.runtime.event.dispatch -> app.runtime.correlation",
|
||||||
"app.runtime.event.dispatch -> app.runtime.event",
|
"app.runtime.event.dispatch -> app.runtime.event",
|
||||||
"app.runtime.event.dispatch -> app.runtime.event.binding",
|
"app.runtime.event.dispatch -> app.runtime.event.binding",
|
||||||
"app.runtime.event.dispatch -> app.runtime.event.registry",
|
"app.runtime.event.dispatch -> app.runtime.event.registry",
|
||||||
@@ -5372,6 +5379,7 @@
|
|||||||
"app.runtime.events -> app.foundation.singleton",
|
"app.runtime.events -> app.foundation.singleton",
|
||||||
"app.runtime.events -> app.runtime",
|
"app.runtime.events -> app.runtime",
|
||||||
"app.runtime.events -> app.runtime.config",
|
"app.runtime.events -> app.runtime.config",
|
||||||
|
"app.runtime.events -> app.runtime.correlation",
|
||||||
"app.runtime.events -> app.runtime.event",
|
"app.runtime.events -> app.runtime.event",
|
||||||
"app.runtime.events -> app.runtime.event.binding",
|
"app.runtime.events -> app.runtime.event.binding",
|
||||||
"app.runtime.events -> app.runtime.event.contracts",
|
"app.runtime.events -> app.runtime.event.contracts",
|
||||||
@@ -5558,6 +5566,7 @@
|
|||||||
"app.scheduler -> app.foundation.singleton",
|
"app.scheduler -> app.foundation.singleton",
|
||||||
"app.scheduler -> app.runtime",
|
"app.scheduler -> app.runtime",
|
||||||
"app.scheduler -> app.runtime.config",
|
"app.scheduler -> app.runtime.config",
|
||||||
|
"app.scheduler -> app.runtime.correlation",
|
||||||
"app.scheduler -> app.runtime.events",
|
"app.scheduler -> app.runtime.events",
|
||||||
"app.scheduler -> app.runtime.extensions",
|
"app.scheduler -> app.runtime.extensions",
|
||||||
"app.scheduler -> app.runtime.extensions.plugin_manager",
|
"app.scheduler -> app.runtime.extensions.plugin_manager",
|
||||||
@@ -6240,7 +6249,7 @@
|
|||||||
"app.workflow.actions.transfer_file -> app.workflow",
|
"app.workflow.actions.transfer_file -> app.workflow",
|
||||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||||
],
|
],
|
||||||
"module_count": 776,
|
"module_count": 778,
|
||||||
"modules": [
|
"modules": [
|
||||||
"app",
|
"app",
|
||||||
"app.adapters",
|
"app.adapters",
|
||||||
@@ -6280,6 +6289,7 @@
|
|||||||
"app.adapters.system.rust",
|
"app.adapters.system.rust",
|
||||||
"app.adapters.system.stdio",
|
"app.adapters.system.stdio",
|
||||||
"app.adapters.web",
|
"app.adapters.web",
|
||||||
|
"app.adapters.web.correlation",
|
||||||
"app.adapters.web.health",
|
"app.adapters.web.health",
|
||||||
"app.adapters.web.plugin",
|
"app.adapters.web.plugin",
|
||||||
"app.adapters.web.plugin.routes",
|
"app.adapters.web.plugin.routes",
|
||||||
@@ -6870,6 +6880,7 @@
|
|||||||
"app.runtime.compat.manifest",
|
"app.runtime.compat.manifest",
|
||||||
"app.runtime.compat.resource_imports",
|
"app.runtime.compat.resource_imports",
|
||||||
"app.runtime.config",
|
"app.runtime.config",
|
||||||
|
"app.runtime.correlation",
|
||||||
"app.runtime.debounce",
|
"app.runtime.debounce",
|
||||||
"app.runtime.event",
|
"app.runtime.event",
|
||||||
"app.runtime.event.binding",
|
"app.runtime.event.binding",
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""请求关联 ID 在 HTTP、线程、事件和外部请求边界的传播测试。"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.responses import JSONResponse, StreamingResponse
|
||||||
|
from starlette.routing import Route
|
||||||
|
|
||||||
|
from app.adapters.network.http import AsyncRequestUtils, RequestUtils
|
||||||
|
from app.adapters.web.correlation import CorrelationIdMiddleware
|
||||||
|
from app.runtime.correlation import (
|
||||||
|
CORRELATION_ID_HEADER,
|
||||||
|
call_with_correlation,
|
||||||
|
correlation_scope,
|
||||||
|
get_correlation_id,
|
||||||
|
normalize_correlation_id,
|
||||||
|
)
|
||||||
|
from app.runtime.event.dispatch import EventDispatcher
|
||||||
|
from app.runtime.events import Event
|
||||||
|
from app.runtime.execution import run_in_threadpool
|
||||||
|
from app.runtime.log import CustomFormatter, configure_correlation_id_provider
|
||||||
|
from app.schemas.types import EventType
|
||||||
|
|
||||||
|
|
||||||
|
def _correlation_app() -> Starlette:
|
||||||
|
"""构造同时包含普通响应和 SSE 风格流式响应的最小应用。"""
|
||||||
|
|
||||||
|
async def current_id(_request):
|
||||||
|
"""返回当前协程看到的关联 ID。"""
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
return JSONResponse({"request_id": get_correlation_id()})
|
||||||
|
|
||||||
|
async def stream_id(_request):
|
||||||
|
"""在响应开始后读取关联 ID,验证上下文覆盖完整流生命周期。"""
|
||||||
|
async def content():
|
||||||
|
"""生成一条 SSE 数据。"""
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
yield f"data: {get_correlation_id()}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(content(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
app = Starlette(
|
||||||
|
routes=[
|
||||||
|
Route("/id", current_id),
|
||||||
|
Route("/stream", stream_id),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
app.add_middleware(CorrelationIdMiddleware)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_concurrent_requests_keep_isolated_ids_and_stream_context() -> None:
|
||||||
|
"""并发请求及流式响应必须各自保留入口 ID。"""
|
||||||
|
transport = httpx.ASGITransport(app=_correlation_app())
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
first, second = await asyncio.gather(
|
||||||
|
client.get("/id", headers={CORRELATION_ID_HEADER: "request-one"}),
|
||||||
|
client.get("/id", headers={CORRELATION_ID_HEADER: "request-two"}),
|
||||||
|
)
|
||||||
|
stream = await client.get(
|
||||||
|
"/stream", headers={CORRELATION_ID_HEADER: "stream-request"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.json()["request_id"] == first.headers[CORRELATION_ID_HEADER]
|
||||||
|
assert second.json()["request_id"] == second.headers[CORRELATION_ID_HEADER]
|
||||||
|
assert first.headers[CORRELATION_ID_HEADER] != second.headers[CORRELATION_ID_HEADER]
|
||||||
|
assert stream.headers[CORRELATION_ID_HEADER] == "stream-request"
|
||||||
|
assert stream.text == "data: stream-request\n\n"
|
||||||
|
assert get_correlation_id() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_request_id_is_replaced_and_threadpool_copies_context() -> None:
|
||||||
|
"""日志注入型入口值被替换,线程池仍读取替换后的安全 ID。"""
|
||||||
|
transport = httpx.ASGITransport(app=_correlation_app())
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/id", headers={CORRELATION_ID_HEADER: "bad id!"}
|
||||||
|
)
|
||||||
|
|
||||||
|
generated = response.headers[CORRELATION_ID_HEADER]
|
||||||
|
assert generated != "bad id!"
|
||||||
|
assert normalize_correlation_id(generated) == generated
|
||||||
|
with correlation_scope("thread-request"):
|
||||||
|
observed = await run_in_threadpool(get_correlation_id)
|
||||||
|
assert observed == "thread-request"
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_dispatch_restores_producer_correlation_id() -> None:
|
||||||
|
"""后台事件处理器使用事件生产时固化的 ID,而非消费者线程上下文。"""
|
||||||
|
observed = []
|
||||||
|
|
||||||
|
def handler(_event):
|
||||||
|
"""记录处理器实际看到的关联 ID。"""
|
||||||
|
observed.append(get_correlation_id())
|
||||||
|
|
||||||
|
resolver = MagicMock()
|
||||||
|
resolver.resolve.return_value = (
|
||||||
|
handler,
|
||||||
|
SimpleNamespace(owner_name="test", run_sync_in_threadpool=False),
|
||||||
|
"Handler",
|
||||||
|
"handle",
|
||||||
|
)
|
||||||
|
registry = MagicMock()
|
||||||
|
dispatcher = EventDispatcher(
|
||||||
|
registry=registry,
|
||||||
|
binding_resolver=resolver,
|
||||||
|
executor=MagicMock(),
|
||||||
|
event_loop=MagicMock(),
|
||||||
|
event_factory=Event,
|
||||||
|
error_handler=MagicMock(),
|
||||||
|
)
|
||||||
|
with correlation_scope("producer-request"):
|
||||||
|
event = Event(EventType.SystemError, {})
|
||||||
|
with correlation_scope("consumer-request"):
|
||||||
|
dispatcher.invoke_sync(handler, event)
|
||||||
|
|
||||||
|
assert event.correlation_id == "producer-request"
|
||||||
|
assert observed == ["producer-request"]
|
||||||
|
assert get_correlation_id() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_external_request_and_formatter_receive_correlation_id() -> None:
|
||||||
|
"""同步外呼头和结构化日志字段使用同一个当前 ID。"""
|
||||||
|
configure_correlation_id_provider(get_correlation_id)
|
||||||
|
response = httpx.Response(200)
|
||||||
|
session = MagicMock()
|
||||||
|
session.request.return_value = response
|
||||||
|
formatter = CustomFormatter("%(correlation_id)s %(message)s")
|
||||||
|
record = logging.LogRecord("test", logging.INFO, "", 0, "message", (), None)
|
||||||
|
|
||||||
|
with correlation_scope("outgoing-request"):
|
||||||
|
RequestUtils(session=session).request("GET", "https://example.com")
|
||||||
|
rendered = formatter.format(record)
|
||||||
|
|
||||||
|
headers = session.request.call_args.kwargs["headers"]
|
||||||
|
assert headers[CORRELATION_ID_HEADER] == "outgoing-request"
|
||||||
|
assert rendered == "outgoing-request message"
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_entry_restores_serialized_correlation_id() -> None:
|
||||||
|
"""多进程入口使用显式 payload 恢复 ID,不依赖 fork 偶然继承上下文。"""
|
||||||
|
with correlation_scope("parent-request"):
|
||||||
|
observed = call_with_correlation(
|
||||||
|
"serialized-request",
|
||||||
|
get_correlation_id,
|
||||||
|
(),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert observed == "serialized-request"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_external_request_preserves_explicit_header() -> None:
|
||||||
|
"""异步外呼默认传播当前 ID,但不得覆盖调用方显式 trace 边界。"""
|
||||||
|
observed = []
|
||||||
|
|
||||||
|
async def respond(request: httpx.Request) -> httpx.Response:
|
||||||
|
"""记录 MockTransport 收到的请求头。"""
|
||||||
|
observed.append(request.headers[CORRELATION_ID_HEADER])
|
||||||
|
return httpx.Response(200)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client:
|
||||||
|
utils = AsyncRequestUtils(client=client)
|
||||||
|
with correlation_scope("context-request"):
|
||||||
|
await utils.request("GET", "https://example.com/default")
|
||||||
|
await utils.request(
|
||||||
|
"GET",
|
||||||
|
"https://example.com/explicit",
|
||||||
|
headers={CORRELATION_ID_HEADER: "explicit-request"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert observed == ["context-request", "explicit-request"]
|
||||||
Reference in New Issue
Block a user