feat: propagate request correlation context

This commit is contained in:
jxxghp
2026-08-21 22:08:33 +08:00
parent c755c074b9
commit 47f1ff9cb4
13 changed files with 408 additions and 41 deletions
+11 -3
View File
@@ -17,6 +17,8 @@ from requests import Response, Session
from urllib3.exceptions import InsecureRequestWarning
from urllib.parse import unquote, quote
from app.runtime.correlation import with_correlation_header
urllib3.disable_warnings(InsecureRequestWarning)
@@ -418,7 +420,9 @@ class RequestUtils:
req_method = requests.request
else:
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("proxies", self._proxies)
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 覆盖以尊重实例配置
kwargs.setdefault("timeout", self._timeout)
# Cookie 在 request() 入口已按 path 处理:
@@ -1329,7 +1335,9 @@ class AsyncRequestUtils:
:return: 上下文管理器,进入后 yield httpx.Response(出错时 yield 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 的连接,
# 流式 GET 是幂等的,单次重试即可
+44
View File
@@ -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
View File
@@ -8,6 +8,7 @@ from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException
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.health import install_health_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.runtime.extensions.plugin_manager import PluginManager
from app.runtime.config import settings
from app.runtime.correlation import get_correlation_id
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 (
AnthropicErrorDetail,
AnthropicErrorResponse,
@@ -291,6 +293,7 @@ def create_app() -> FastAPI:
"""
创建并配置 FastAPI 应用实例。
"""
configure_correlation_id_provider(get_correlation_id)
_app = FastAPI(
title=settings.PROJECT_NAME,
version=APP_VERSION,
@@ -317,6 +320,7 @@ def create_app() -> FastAPI:
allow_methods=["*"],
allow_headers=["*"],
)
_app.add_middleware(CorrelationIdMiddleware)
@_app.middleware("http")
async def locale_context_middleware(
+74
View File
@@ -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)
+29 -25
View File
@@ -12,6 +12,7 @@ from app.runtime.event.binding import EventBindingResolver
from app.runtime.event.registry import EventRegistry
from app.runtime.execution import run_in_threadpool
from app.runtime.log import logger
from app.runtime.correlation import correlation_scope
from app.schemas.types import EventType
@@ -114,6 +115,7 @@ class EventDispatcher:
event_type=event.event_type,
event_data=event_data,
priority=event.priority,
correlation_id=event.correlation_id,
)
if inspect.iscoroutinefunction(handler):
asyncio.run_coroutine_threadsafe(
@@ -143,16 +145,17 @@ class EventDispatcher:
if not resolved:
return
method, binding, class_name, method_name = resolved
try:
method(event)
except Exception as err:
self._error_handler(
event=event,
module_name=binding.owner_name,
class_name=class_name,
method_name=method_name,
e=err,
)
with correlation_scope(event.correlation_id):
try:
method(event)
except Exception as err:
self._error_handler(
event=event,
module_name=binding.owner_name,
class_name=class_name,
method_name=method_name,
e=err,
)
async def invoke_async(self, handler: Callable, event: Any) -> None:
"""解析实例绑定,并按处理器类型选择协程、线程池或同步调用。"""
@@ -160,21 +163,22 @@ class EventDispatcher:
if not resolved:
return
method, binding, class_name, method_name = resolved
try:
if inspect.iscoroutinefunction(method):
await method(event)
elif binding.run_sync_in_threadpool or not class_name:
await run_in_threadpool(method, event)
else:
method(event)
except Exception as err:
self._error_handler(
event=event,
module_name=binding.owner_name,
class_name=class_name,
method_name=method_name,
e=err,
)
with correlation_scope(event.correlation_id):
try:
if inspect.iscoroutinefunction(method):
await method(event)
elif binding.run_sync_in_threadpool or not class_name:
await run_in_threadpool(method, event)
else:
method(event)
except Exception as err:
self._error_handler(
event=event,
module_name=binding.owner_name,
class_name=class_name,
method_name=method_name,
e=err,
)
@staticmethod
def should_dispatch_to_target_plugin(
+5 -1
View File
@@ -21,6 +21,7 @@ from app.runtime.event.dispatch import EventDispatcher
from app.runtime.event.errors import EventErrorNotifier, EventErrorPolicy
from app.runtime.event.registry import EventRegistry
from app.runtime.event.contracts import validate_event_payload
from app.runtime.correlation import get_correlation_id
DEFAULT_EVENT_PRIORITY = 10 # 事件的默认优先级
MIN_EVENT_CONSUMER_THREADS = 1 # 最小事件消费者线程数
@@ -35,11 +36,13 @@ class Event:
def __init__(self, event_type: Union[EventType, ChainEventType],
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_data: 可选,事件携带的数据,默认为空字典
:param priority: 可选,事件的优先级,默认为 10
:param correlation_id: 生产事件时固化的请求关联 ID
"""
payload_problems = validate_event_payload(event_type, event_data)
if payload_problems:
@@ -52,6 +55,7 @@ class Event:
self.event_type = event_type # 事件类型
self.event_data = event_data or {} # 事件数据
self.priority = priority # 事件优先级
self.correlation_id = correlation_id or get_correlation_id()
def __repr__(self) -> str:
"""
+3 -1
View File
@@ -1,6 +1,7 @@
import asyncio
import inspect
import time
from contextvars import copy_context
from functools import partial, wraps
from typing import Any, Callable
@@ -16,7 +17,8 @@ async def run_in_threadpool(
"""在线程中执行同步函数,保持 FastAPI 旧帮助函数的参数语义。"""
if 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,
+24 -5
View File
@@ -12,12 +12,11 @@ from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Any, Dict, Optional, Protocol
from typing import Any, Callable, Dict, Optional, Protocol
import click
from pydantic import BaseModel, ConfigDict
class LogConfigModel(BaseModel):
"""描述日志级别、格式和文件写入策略。"""
@@ -28,8 +27,12 @@ class LogConfigModel(BaseModel):
LOG_LEVEL: str = "INFO"
LOG_MAX_FILE_SIZE: int = 5
LOG_BACKUP_COUNT: int = 10
LOG_CONSOLE_FORMAT: str = "%(leveltext)s[%(name)s] %(asctime)s %(message)s"
LOG_FILE_FORMAT: str = "%(levelname)s%(asctime)s - %(message)s"
LOG_CONSOLE_FORMAT: str = (
"%(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_WORKERS: int = 2
BATCH_WRITE_SIZE: int = 50
@@ -60,6 +63,7 @@ class LogEntry:
self.message = message
self.file_path = file_path
self.timestamp = timestamp or datetime.now()
self.correlation_id = _get_log_correlation_id()
class LogWriter(Protocol):
@@ -73,6 +77,18 @@ class LogWriter(Protocol):
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:
@@ -169,7 +185,7 @@ class NonBlockingFileHandler:
@staticmethod
def _to_record(entry: LogEntry) -> logging.LogRecord:
"""把日志条目转换为标准库日志记录。"""
return logging.LogRecord(
record = logging.LogRecord(
name="",
level=getattr(logging, entry.level.upper(), logging.INFO),
pathname="",
@@ -179,6 +195,8 @@ class NonBlockingFileHandler:
exc_info=None,
created=entry.timestamp.timestamp(),
)
record.correlation_id = entry.correlation_id
return record
def _batch_writer(self) -> None:
"""持续收集队列日志,并在停止哨兵后排空已有批次。"""
@@ -258,6 +276,7 @@ class CustomFormatter(logging.Formatter):
separator = " " * max(8 - len(record.levelname), 0)
colorizer = _LEVEL_NAME_COLORS.get(record.levelno, str)
record.leveltext = colorizer(record.levelname + ":") + separator
record.correlation_id = _get_log_correlation_id()
return super().format(record)
+3 -1
View File
@@ -1,4 +1,5 @@
from concurrent.futures import ThreadPoolExecutor
from contextvars import copy_context
from app.runtime.config import settings
from app.foundation.singleton import Singleton
@@ -20,7 +21,8 @@ class ThreadHelper(metaclass=Singleton):
:param kwargs: 参数
:return: future
"""
return self.pool.submit(func, *args, **kwargs)
context = copy_context()
return self.pool.submit(context.run, func, *args, **kwargs)
def shutdown(self):
"""
+5 -1
View File
@@ -45,6 +45,7 @@ from app.runtime.gc import get_memory_usage
from app.runtime.reload import ConfigReloadMixin
from app.foundation.singleton import SingletonClass
from app.runtime.scheduling import TimerUtils
from app.runtime.correlation import call_with_correlation, get_correlation_id
lock = threading.Lock()
SCHEDULER_PROGRESS_PREFIX = "scheduler"
@@ -856,7 +857,10 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
deferred_finish = __start_coro(func(*args, **kwargs))
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.join()
else:
@@ -731,6 +731,17 @@ app/scheduler.py # APScheduler 兼容 Facade
4. 日志 formatter 增加结构字段,不在消息字符串中到处手拼。
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
先定义内部观测端口和低基数指标:
+14 -3
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6223,
"edge_sha256": "f64e5083ab697022127780800617474d12f10b05a6fea26787e751a3f8a00421",
"edge_count": 6232,
"edge_sha256": "2139dcdcfc12b4f29c732471744d09b0990c51867d4ad227026b00fa947c90d8",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -104,6 +104,8 @@
"app.adapters.network.doh -> app.runtime.config",
"app.adapters.network.doh -> app.runtime.log",
"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.singleton",
"app.adapters.system.display -> app.runtime",
@@ -151,6 +153,8 @@
"app.adapters.system.rust -> app.runtime",
"app.adapters.system.rust -> app.runtime.config",
"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.health",
"app.adapters.web.security.access -> app.runtime",
@@ -3719,6 +3723,7 @@
"app.domain.title -> app.schemas.types",
"app.factory -> app.adapters",
"app.factory -> app.adapters.web",
"app.factory -> app.adapters.web.correlation",
"app.factory -> app.adapters.web.health",
"app.factory -> app.adapters.web.plugin",
"app.factory -> app.adapters.web.plugin.routes",
@@ -3733,6 +3738,7 @@
"app.factory -> app.application.security.token",
"app.factory -> app.runtime",
"app.factory -> app.runtime.config",
"app.factory -> app.runtime.correlation",
"app.factory -> app.runtime.extensions",
"app.factory -> app.runtime.extensions.plugin_manager",
"app.factory -> app.runtime.localization",
@@ -5353,6 +5359,7 @@
"app.runtime.event.contracts -> app.schemas.event",
"app.runtime.event.contracts -> app.schemas.types",
"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.binding",
"app.runtime.event.dispatch -> app.runtime.event.registry",
@@ -5372,6 +5379,7 @@
"app.runtime.events -> app.foundation.singleton",
"app.runtime.events -> app.runtime",
"app.runtime.events -> app.runtime.config",
"app.runtime.events -> app.runtime.correlation",
"app.runtime.events -> app.runtime.event",
"app.runtime.events -> app.runtime.event.binding",
"app.runtime.events -> app.runtime.event.contracts",
@@ -5558,6 +5566,7 @@
"app.scheduler -> app.foundation.singleton",
"app.scheduler -> app.runtime",
"app.scheduler -> app.runtime.config",
"app.scheduler -> app.runtime.correlation",
"app.scheduler -> app.runtime.events",
"app.scheduler -> app.runtime.extensions",
"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.actions"
],
"module_count": 776,
"module_count": 778,
"modules": [
"app",
"app.adapters",
@@ -6280,6 +6289,7 @@
"app.adapters.system.rust",
"app.adapters.system.stdio",
"app.adapters.web",
"app.adapters.web.correlation",
"app.adapters.web.health",
"app.adapters.web.plugin",
"app.adapters.web.plugin.routes",
@@ -6870,6 +6880,7 @@
"app.runtime.compat.manifest",
"app.runtime.compat.resource_imports",
"app.runtime.config",
"app.runtime.correlation",
"app.runtime.debounce",
"app.runtime.event",
"app.runtime.event.binding",
+180
View File
@@ -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"]