modify: 日志重复问题

This commit is contained in:
2026-08-25 09:29:27 +08:00
parent eeffc06841
commit 4d6b759a1b
8 changed files with 317 additions and 24 deletions
+4
View File
@@ -88,6 +88,9 @@ base = [
"utils.config", "utils.config",
"zenoh", "zenoh",
"chongming_worker", "chongming_worker",
"chongming_worker.contracts",
"chongming_worker.route_config",
"chongming_worker.routes_publisher",
"chongming_request", "chongming_request",
"server", "server",
"module_bank", # 模块化打包工具 "module_bank", # 模块化打包工具
@@ -96,6 +99,7 @@ base = [
"fastapi", # web 应用依赖 "fastapi", # web 应用依赖
"fastapi.staticfiles", "fastapi.staticfiles",
"fastapi.middleware.cors", "fastapi.middleware.cors",
"fastapi.middleware.gzip",
"uvicorn", "uvicorn",
"apscheduler", "apscheduler",
"diskcache", "diskcache",
+8 -5
View File
@@ -3,6 +3,12 @@ import os
from module_bank import PythonToSQLite from module_bank import PythonToSQLite
from typing import Callable from typing import Callable
# module_bank 依赖在每次加载 PyArmor 加密模块时都会 print 一行
# "已部署 PyArmor 运行时",启动期会刷屏(约 50 行)。依赖包不便改动,
# 这里用模块级 print 遮蔽(模块内名字解析 globals 优先于 builtins)静默掉。
import module_bank.sqlite_module_loader as _sqlite_module_loader
_sqlite_module_loader.print = lambda *args, **kwargs: None
def init_module_bank(config): def init_module_bank(config):
@@ -10,7 +16,7 @@ def init_module_bank(config):
初始化模块银行系统,将指定路径下的Python模块打包并安装导入器。 初始化模块银行系统,将指定路径下的Python模块打包并安装导入器。
该函数会验证所有模块路径的有效性,创建PythonToSQLite打包器实例, 该函数会验证所有模块路径的有效性,创建PythonToSQLite打包器实例,
列出所有可用模块,并为每个打包器安装导入器以支持模块的动态加载。 并为每个打包器安装导入器以支持模块的动态加载。
Args: Args:
config (dict): 配置字典,必须包含 'production' -> 'module_system' -> 'path' 键, config (dict): 配置字典,必须包含 'production' -> 'module_system' -> 'path' 键,
@@ -28,12 +34,9 @@ def init_module_bank(config):
packer_list = [ packer_list = [
PythonToSQLite(module_path) for module_path in MODULE_BANK_PATH_LIST PythonToSQLite(module_path) for module_path in MODULE_BANK_PATH_LIST
] ]
# 遍历所有打包器,列出模块信息并安装导入器 # 遍历所有打包器,安装导入器(模块清单不再打印,避免启动刷屏)
for packer in packer_list: for packer in packer_list:
key = packer.get_key() key = packer.get_key()
modules = packer.list_modules()
for module in modules:
print(f"{module['module_name']} {'[包]' if module['is_package'] else ''}")
packer.install_importer(key=key) packer.install_importer(key=key)
+21 -1
View File
@@ -15,10 +15,13 @@ record_call 内部全兜底:统计或审计任一步失败仅记 warning,绝
import asyncio import asyncio
import datetime import datetime
import orjson import orjson
import random
import time import time
from collections import deque from collections import deque
from typing import Any, Coroutine, Dict from typing import Any, Coroutine, Dict
from chongming_config import get_config
from .logger import get_logger from .logger import get_logger
logger = get_logger("call_stats") logger = get_logger("call_stats")
@@ -28,6 +31,22 @@ audit_logger = get_logger("audit")
# 最近调用环形缓冲上限 # 最近调用环形缓冲上限
MAX_RECENT = 1000 MAX_RECENT = 1000
# 审计日志采样率:与 access 日志同源配置(access_sample_rate,0.1 = 只写 10% 的成功请求;
# 错误 status >= 400 始终全量)。压测实测全量审计写盘 8.8MB/4.3 万行,是高吞吐下的隐藏 I/O 大头。
_audit_config = get_config()
_audit_env = _audit_config["default"]["env"]
_AUDIT_SAMPLE_RATE = float(
_audit_config[_audit_env].get("logging", {}).get("access_sample_rate", 1.0)
)
def _should_record_audit(status: int) -> bool:
"""审计日志采样判定(与 access 日志同模式)"""
if status >= 400 or _AUDIT_SAMPLE_RATE >= 1.0:
return True
denom = max(1, int(round(1.0 / _AUDIT_SAMPLE_RATE)))
return random.randrange(denom) == 0
# 后台任务集合(fire-and-forget,shutdown 时统一取消) # 后台任务集合(fire-and-forget,shutdown 时统一取消)
_tasks: set[asyncio.Task] = set() _tasks: set[asyncio.Task] = set()
@@ -145,6 +164,7 @@ async def record_call(entry: Dict[str, Any]) -> None:
except Exception as e: # pragma: no cover - 防御性兜底 except Exception as e: # pragma: no cover - 防御性兜底
logger.warning("调用统计记录失败: %s", e) logger.warning("调用统计记录失败: %s", e)
try: try:
audit_logger.info(_format_audit(entry)) if _should_record_audit(entry.get("status", 200)):
audit_logger.info(_format_audit(entry))
except Exception as e: # pragma: no cover - 防御性兜底 except Exception as e: # pragma: no cover - 防御性兜底
logger.warning("审计日志写入失败: %s", e) logger.warning("审计日志写入失败: %s", e)
+9 -1
View File
@@ -83,6 +83,14 @@ def _run_mqtt_broker(host: str, port: int, username: str | None = None,
import asyncio as _asyncio import asyncio as _asyncio
from amqtt.broker import Broker from amqtt.broker import Broker
# amqtt 在 broker 进程内无 handler,WARNING 会经 lastResort 直接刷到 stderr:
# - sys_interval=0(禁用 $SYS)被 amqtt 当 falsy 报警
# - 客户端连上却不发 CONNECT 时每次刷 2 行 "No data from"/"Failed to initialize..."
# 统一压到 ERROR:认证失败本身是 DEBUG 级不受影响,真实故障仍会输出
import logging as _logging
_logging.getLogger("amqtt").setLevel(_logging.ERROR)
# ---- monkey-patch 1: amqtt StreamWriterAdapter.close() 忽略断开的传输层 ---- # ---- monkey-patch 1: amqtt StreamWriterAdapter.close() 忽略断开的传输层 ----
from amqtt.adapters import StreamWriterAdapter as _SWA from amqtt.adapters import StreamWriterAdapter as _SWA
@@ -209,7 +217,7 @@ def _run_mqtt_broker(host: str, port: int, username: str | None = None,
except OSError: except OSError:
pass pass
auth_note = f" (认证用户: {username})" if username else " (匿名)" auth_note = f" (认证用户: {username})" if username else " (匿名)"
print(f"[MQTT Broker] 已启动 @ {host}:{port}{auth_note}") logger.info("MQTT Broker 已启动 @ %s:%s%s", host, port, auth_note)
try: try:
while True: while True:
await _asyncio.sleep(1) await _asyncio.sleep(1)
+29 -4
View File
@@ -145,6 +145,24 @@ class DailyRotatingFileHandler(RotatingFileHandler):
super().emit(record) super().emit(record)
class LoggerNameFilter(logging.Filter):
"""
按 log_name 路由日志记录的过滤器
只放行本 log_name 自身及其子 logger 的记录(如 chongming_worker.runtime
归入 chongming_worker),保证共享 QueueListener 下每个 handler 只处理
属于自己的记录:控制台每行只打印一次,各日志文件按名隔离。
"""
def __init__(self, log_name: str):
super().__init__()
self._name = log_name
self._prefix = log_name + "."
def filter(self, record):
return record.name == self._name or record.name.startswith(self._prefix)
class ExcludeWatchFilesFilter(logging.Filter): class ExcludeWatchFilesFilter(logging.Filter):
""" """
日志过滤器,用于排除第三方库的日志输出 日志过滤器,用于排除第三方库的日志输出
@@ -279,6 +297,8 @@ atexit.register(shutdown_logging)
_configured_loggers = set() _configured_loggers = set()
# 每个进程只打印一次日志配置信息(setup_logging 每个 log_name 都会调用一次)
_config_info_printed = False
def setup_logging(logger: logging.Logger, log_name: str = "app"): def setup_logging(logger: logging.Logger, log_name: str = "app"):
@@ -332,11 +352,13 @@ def setup_logging(logger: logging.Logger, log_name: str = "app"):
) )
file_handler.setFormatter(plain_formatter) file_handler.setFormatter(plain_formatter)
file_handler.addFilter(ExcludeWatchFilesFilter()) file_handler.addFilter(ExcludeWatchFilesFilter())
file_handler.addFilter(LoggerNameFilter(log_name))
# 创建控制台处理器并添加过滤器 # 创建控制台处理器并添加过滤器
console_handler = logging.StreamHandler() console_handler = logging.StreamHandler()
console_handler.setFormatter(color_formatter) console_handler.setFormatter(color_formatter)
console_handler.addFilter(ExcludeWatchFilesFilter()) console_handler.addFilter(ExcludeWatchFilesFilter())
console_handler.addFilter(LoggerNameFilter(log_name))
# 真实 handler(文件/控制台)挂到后台监听线程;logger 上只挂队列 handler, # 真实 handler(文件/控制台)挂到后台监听线程;logger 上只挂队列 handler,
# 日志调用在主线程仅入队,I/O 由监听线程执行 # 日志调用在主线程仅入队,I/O 由监听线程执行
@@ -347,10 +369,13 @@ def setup_logging(logger: logging.Logger, log_name: str = "app"):
_listener.register(file_handler, console_handler) _listener.register(file_handler, console_handler)
logger.addHandler(_AsyncQueueHandler()) logger.addHandler(_AsyncQueueHandler())
print( global _config_info_printed
f"日志系统已配置 - 级别: {logging.getLevelName(log_level)}, 文件: {log_config['file']} " if not _config_info_printed:
f"(最大:{max_bytes / 1024 / 1024:.1f}MB, 备份:{backup_count})" _config_info_printed = True
) print(
f"日志系统已配置 - 级别: {logging.getLevelName(log_level)}, 文件: {log_config['file']} "
f"(最大:{max_bytes / 1024 / 1024:.1f}MB, 备份:{backup_count})"
)
def get_logger(name: str) -> logging.Logger: def get_logger(name: str) -> logging.Logger:
+16 -9
View File
@@ -110,9 +110,12 @@ class WorkerManager:
await asyncio.sleep(self._refresh_interval) await asyncio.sleep(self._refresh_interval)
async def get_worker(self, worker_type: str) -> Optional[str]: async def get_worker(self, worker_type: str) -> Optional[str]:
"""获取一个可用的 worker 地址(简单随机负载均衡)""" """获取一个可用的 worker 地址(简单随机负载均衡)
async with self._lock:
workers = self._workers_cache.get(worker_type, []) 读路径无锁:_workers_cache 由 refresh_workers 整表原子替换,
_local_workers 由同步方法修改,单事件循环内读取安全。
"""
workers = self._workers_cache.get(worker_type, [])
# 如果缓存为空,回退到本地注册表(适用于启动初期 refresh_workers 尚未执行时) # 如果缓存为空,回退到本地注册表(适用于启动初期 refresh_workers 尚未执行时)
if not workers: if not workers:
local = self._local_workers.get(worker_type, []) local = self._local_workers.get(worker_type, [])
@@ -134,8 +137,7 @@ class WorkerManager:
Returns: Returns:
可用的 worker key_expr,若无其他可用则返回 None 可用的 worker key_expr,若无其他可用则返回 None
""" """
async with self._lock: workers = self._workers_cache.get(worker_type, [])
workers = self._workers_cache.get(worker_type, [])
candidates = [w for w in workers if w not in exclude] candidates = [w for w in workers if w not in exclude]
if candidates: if candidates:
return random.choice(candidates) return random.choice(candidates)
@@ -162,8 +164,7 @@ class WorkerManager:
供 call() / call_stream() 共用,保证两类调用错误信息一致。 供 call() / call_stream() 共用,保证两类调用错误信息一致。
""" """
async with self._lock: all_known = list(self._workers_cache.keys())
all_known = list(self._workers_cache.keys())
local_known = list(self._local_workers.keys()) local_known = list(self._local_workers.keys())
all_types = sorted(set(all_known + local_known)) all_types = sorted(set(all_known + local_known))
msg = ( msg = (
@@ -289,7 +290,10 @@ class WorkerManager:
Returns: Returns:
Dict[str, Any]: 调用结果(worker 回复解析后的 dict) Dict[str, Any]: 调用结果(worker 回复解析后的 dict)
""" """
cache_key = f"{worker_type}:{subject}:{orjson.dumps(payload, option=orjson.OPT_SORT_KEYS).decode()}" # cacheable=False 时不计算 cache_key,省一次排序序列化
cache_key = None
if cacheable:
cache_key = f"{worker_type}:{subject}:{orjson.dumps(payload, option=orjson.OPT_SORT_KEYS).decode()}"
reply_bytes = await self._call_impl( reply_bytes = await self._call_impl(
worker_type, subject, orjson.dumps(payload), worker_type, subject, orjson.dumps(payload),
timeout=timeout, retries=retries, cacheable=cacheable, cache_key=cache_key, timeout=timeout, retries=retries, cacheable=cacheable, cache_key=cache_key,
@@ -325,7 +329,10 @@ class WorkerManager:
Returns: Returns:
bytes: worker 回复的原始字节(空回复兜底 b"{}",与 call() 的 {} 语义一致) bytes: worker 回复的原始字节(空回复兜底 b"{}",与 call() 的 {} 语义一致)
""" """
cache_key = f"{worker_type}:{subject}:{hashlib.sha256(payload).hexdigest()}" # cacheable=False 时不计算 cache_key(当前唯一用途),省一次 sha256
cache_key = None
if cacheable:
cache_key = f"{worker_type}:{subject}:{hashlib.sha256(payload).hexdigest()}"
return await self._call_impl( return await self._call_impl(
worker_type, subject, payload, worker_type, subject, payload,
timeout=timeout, retries=retries, cacheable=cacheable, cache_key=cache_key, timeout=timeout, retries=retries, cacheable=cacheable, cache_key=cache_key,
+203
View File
@@ -0,0 +1,203 @@
"""
HTTP 端到端 RPC 压测脚本(chongming 网关全链路)
链路: 压测客户端(asyncio+httpx) → FastAPI 网关 :8000 → Zenoh Query/Reply → worker 进程
前置启动(均后台运行,需在仓库根目录):
1. Zenoh router: uv run python utils/worker-rs/scripts/zenoh_router.py
2. 网关(无热重载): uv run uvicorn chongming.app:app --host 0.0.0.0 --port 8000 --no-access-log
3. Worker: uv run worker_serve --type demo --count 2
运行:
uv run python test/bench_http.py --concurrency 1,10,50,100,200 --duration 30
指标: RPS(总请求/秒)、p50/p95/p99/max 延迟(成功请求, ms)、错误率(非 2xx/超时/连接异常)、
结束后拉取网关侧 GET /api/v1/stats 做服务端对照。
"""
import argparse
import asyncio
import json
import statistics
import sys
import time
from dataclasses import dataclass, field
import httpx
DEFAULT_URL = "http://localhost:8000/api/v1/demo/hello?name=bench"
@dataclass
class Bench:
"""单档压测共享状态(asyncio 单线程内访问,无需加锁)"""
start: float = 0.0
total: int = 0 # 全部完成的请求(含错误/超时)
ok: int = 0 # 2xx 成功请求
timeouts: int = 0
errors: int = 0 # 非 2xx / 连接异常
status_dist: dict = field(default_factory=dict)
lats_ms: list = field(default_factory=list) # 成功请求延迟(ms)
def percentile(sorted_lats: list, p: float) -> float:
"""有序列表上的线性插值分位数(p 为 0~1)"""
if not sorted_lats:
return 0.0
idx = (len(sorted_lats) - 1) * p
lo = int(idx)
hi = min(lo + 1, len(sorted_lats) - 1)
frac = idx - lo
return sorted_lats[lo] * (1 - frac) + sorted_lats[hi] * frac
async def worker(client: httpx.AsyncClient, url: str, bench: Bench, end_time: float) -> None:
loop = asyncio.get_running_loop()
while True:
t0 = loop.time()
if t0 >= end_time:
return
try:
resp = await client.get(url, timeout=10.0)
except httpx.TimeoutException:
bench.timeouts += 1
continue
except httpx.HTTPError:
bench.errors += 1
continue
lat_ms = (loop.time() - t0) * 1000
if resp.status_code == 200:
bench.ok += 1
bench.lats_ms.append(lat_ms)
else:
bench.errors += 1
bench.status_dist[resp.status_code] = bench.status_dist.get(resp.status_code, 0) + 1
bench.total += 1
async def progress_reporter(bench: Bench, duration: float) -> None:
"""每 5s 打印进度(RPS/成功/错误),供长压测观测"""
while True:
await asyncio.sleep(5)
elapsed = time.perf_counter() - bench.start
if elapsed <= 0:
continue
print(f" [{elapsed:5.0f}s] RPS={bench.total / elapsed:7.1f} "
f"ok={bench.ok} err={bench.errors} timeout={bench.timeouts}", flush=True)
async def run_level(client: httpx.AsyncClient, url: str, concurrency: int,
duration: float, warmup: float) -> dict:
"""单档并发:warmup 预热(结果丢弃)→ duration 计时统计"""
bench = Bench()
async def phase(end_time: float) -> None:
tasks = [asyncio.create_task(worker(client, url, bench, end_time))
for _ in range(concurrency)]
await asyncio.gather(*tasks)
loop = asyncio.get_running_loop()
print(f" 预热 {warmup:.0f}s ...", flush=True)
bench.start = time.perf_counter()
await phase(loop.time() + warmup)
bench = Bench() # 丢弃预热结果,正式计时
bench.start = time.perf_counter()
print(f" 压测 {duration:.0f}s (并发 {concurrency}) ...", flush=True)
reporter = asyncio.create_task(progress_reporter(bench, duration))
await phase(loop.time() + duration)
reporter.cancel()
await asyncio.gather(reporter, return_exceptions=True)
elapsed = time.perf_counter() - bench.start
sorted_lats = sorted(bench.lats_ms)
total = bench.total
return {
"concurrency": concurrency,
"duration_s": round(elapsed, 2),
"total": total,
"ok": bench.ok,
"errors": bench.errors,
"timeouts": bench.timeouts,
"error_rate_pct": round((bench.errors + bench.timeouts) / total * 100, 2) if total else 0.0,
"rps": round(total / elapsed, 1) if elapsed else 0.0,
"p50_ms": round(percentile(sorted_lats, 0.50), 2),
"p95_ms": round(percentile(sorted_lats, 0.95), 2),
"p99_ms": round(percentile(sorted_lats, 0.99), 2),
"max_ms": round(sorted_lats[-1], 2) if sorted_lats else 0.0,
"avg_ms": round(statistics.fmean(bench.lats_ms), 2) if bench.lats_ms else 0.0,
"status_dist": dict(sorted(bench.status_dist.items())),
}
async def fetch_stats_snapshot(client: httpx.AsyncClient) -> dict:
"""拉取网关侧 /api/v1/stats 快照(失败返回空 dict)"""
try:
resp = await client.get("http://localhost:8000/api/v1/stats", timeout=5.0)
return resp.json() if resp.status_code == 200 else {}
except Exception:
return {}
async def main() -> int:
parser = argparse.ArgumentParser(description="chongming HTTP 端到端 RPC 压测")
parser.add_argument("--url", default=DEFAULT_URL, help="压测目标 URL")
parser.add_argument("--concurrency", default="100", help="并发数,逗号分隔多档(如 1,10,50,100,200)")
parser.add_argument("--duration", type=float, default=30.0, help="每档压测时长(秒)")
parser.add_argument("--warmup", type=float, default=3.0, help="每档预热时长(秒)")
parser.add_argument("--max-connections", type=int, default=100,
help="httpx 连接池上限(并发数超过此值时客户端会排队)")
parser.add_argument("--output", default=None, help="结果保存为 JSON 文件")
args = parser.parse_args()
levels = [int(c.strip()) for c in args.concurrency.split(",") if c.strip()]
print(f"压测目标: {args.url}")
print(f"并发档位: {levels} | 每档 {args.duration:.0f}s + 预热 {args.warmup:.0f}s")
results = []
limits = httpx.Limits(max_connections=args.max_connections,
max_keepalive_connections=args.max_connections)
async with httpx.AsyncClient(http2=False, limits=limits) as client:
for level in levels:
stats_before = await fetch_stats_snapshot(client)
result = await run_level(client, args.url, level, args.duration, args.warmup)
stats_after = await fetch_stats_snapshot(client)
delta = max(0, stats_after.get("total_calls", 0) - stats_before.get("total_calls", 0))
result["gateway_total_calls_delta"] = delta
results.append(result)
# ---- 汇总表格 ----
print("\n" + "=" * 88)
print(f"{'并发':>5} | {'RPS':>8} | {'avg(ms)':>8} | {'p50':>7} | {'p95':>7} | "
f"{'p99':>7} | {'max':>7} | {'错误率%':>7} | 网关侧计数差")
print("-" * 88)
for r in results:
print(f"{r['concurrency']:>5} | {r['rps']:>8.1f} | {r['avg_ms']:>8.2f} | {r['p50_ms']:>7.2f} | "
f"{r['p95_ms']:>7.2f} | {r['p99_ms']:>7.2f} | {r['max_ms']:>7.2f} | "
f"{r['error_rate_pct']:>7.2f} | {r.get('gateway_total_calls_delta', '-'):>10}")
print("=" * 88)
# ---- 网关侧统计对照 ----
async with httpx.AsyncClient() as client:
stats = await fetch_stats_snapshot(client)
if stats.get("latency"):
print("\n网关侧 /api/v1/stats 延迟统计(进程内聚合,全部档累计):")
for lat in stats["latency"]:
print(f" worker_type={lat['worker_type']:<8} count={lat['count']:<8} "
f"avg={lat['avg_ms']:<8.2f}ms max={lat['max_ms']:.2f}ms")
print(f" 累计调用: {stats.get('total_calls', 0)}")
for c in stats.get("calls", []):
if c["count"]:
print(f" subject={c['subject']:<24} status={c['status']} count={c['count']}")
else:
print("\n(网关侧 /api/v1/stats 无数据或不可用)")
if args.output:
with open(args.output, "w") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
print(f"\n结果已保存: {args.output}")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
+27 -4
View File
@@ -17,14 +17,16 @@ import tempfile
import time import time
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent / "src")) sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent / "utils" / "worker" / "src")) sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "utils" / "worker" / "src"))
_tmp = tempfile.TemporaryDirectory() _tmp = tempfile.TemporaryDirectory()
# 预加载真实 logger 模块(不触发 chongming 包链导入),把日志目录重定向到 # 预加载真实 logger 模块(不触发 chongming 包链导入),把日志目录重定向到
# 临时目录,之后 import chongming.app 时 get_logger 即用新路径 # 临时目录,之后 import chongming.app 时 get_logger 即用新路径
_logger_path = Path(__file__).resolve().parent / "src" / "chongming" / "app" / "core" / "logger.py" _logger_path = (
Path(__file__).resolve().parent.parent / "src" / "chongming" / "app" / "core" / "logger.py"
)
_logger_spec = importlib.util.spec_from_file_location("chongming.app.core.logger", _logger_path) _logger_spec = importlib.util.spec_from_file_location("chongming.app.core.logger", _logger_path)
logger_mod = importlib.util.module_from_spec(_logger_spec) logger_mod = importlib.util.module_from_spec(_logger_spec)
sys.modules["chongming.app.core.logger"] = logger_mod sys.modules["chongming.app.core.logger"] = logger_mod
@@ -103,7 +105,27 @@ def test_queue_full_drops():
logger_mod._log_queue = old logger_mod._log_queue = old
# ---- 4. 端到端:中间件采样 + 异步落盘 ---- # ---- 4. 按名隔离:不同 log_name 的记录不串文件 ----
def test_name_isolation():
logger_mod.get_logger("test_b")
lg_a = logger_mod.get_logger("test_async")
lg_b = logger_mod.get_logger("test_b")
lg_a.info("隔离测试 A 专属 %d", 1)
lg_b.info("隔离测试 B 专属 %d", 2)
ok = _wait_for(
lambda: any(
"隔离测试 B 专属 2" in f.read_text(errors="ignore")
for f in Path(_tmp.name).glob("test_b-*.log")
)
)
check("test_b 记录写入 test_b 文件", ok)
a_text = "".join(f.read_text(errors="ignore") for f in Path(_tmp.name).glob("test_async-*.log"))
b_text = "".join(f.read_text(errors="ignore") for f in Path(_tmp.name).glob("test_b-*.log"))
check("test_async 记录不串入 test_b 文件", "隔离测试 A 专属" not in b_text)
check("test_b 记录不串入 test_async 文件", "隔离测试 B 专属" not in a_text)
# ---- 5. 端到端:中间件采样 + 异步落盘 ----
def test_e2e_middleware(): def test_e2e_middleware():
import asyncio import asyncio
import httpx import httpx
@@ -130,6 +152,7 @@ def test_e2e_middleware():
test_sampling() test_sampling()
test_async_queue_write() test_async_queue_write()
test_queue_full_drops() test_queue_full_drops()
test_name_isolation()
test_e2e_middleware() test_e2e_middleware()
# 关闭监听线程并释放文件句柄,再清理临时目录 # 关闭监听线程并释放文件句柄,再清理临时目录