Files
cmautobuy/client/src/performance_timing.py
T

150 lines
4.9 KiB
Python

"""任务领取到商品页的脱敏分阶段计时。
日志字段使用白名单,只包含任务编号、稳定操作名、毫秒耗时和结果分类。
不要把 URL、控件树、异常正文或业务数据传入本模块。
"""
from __future__ import annotations
import json
import logging
from contextlib import contextmanager
from contextvars import ContextVar
from logging.handlers import RotatingFileHandler
from pathlib import Path
import time
from typing import Callable, Iterator, Optional
from .db import data_dir
PERFORMANCE_LOG_NAME = "task_performance.jsonl"
_LOGGER_NAME = "cmautobuy.performance"
_ACTIVE_TRACE: ContextVar[Optional["TaskPerformanceTrace"]] = ContextVar(
"cmautobuy_task_performance_trace", default=None
)
def _default_sink(record: dict[str, object]) -> None:
logging.getLogger(_LOGGER_NAME).info(
json.dumps(record, ensure_ascii=False, separators=(",", ":"))
)
def configure_performance_logging(
directory: Optional[Path] = None,
) -> Path:
"""把性能事件写入可轮转 JSONL 文件,重复调用不会增加处理器。"""
log_directory = directory or data_dir() / "logs"
log_directory.mkdir(parents=True, exist_ok=True)
log_path = log_directory / PERFORMANCE_LOG_NAME
logger = logging.getLogger(_LOGGER_NAME)
if not any(
getattr(handler, "baseFilename", None) == str(log_path.resolve())
for handler in logger.handlers
):
handler = RotatingFileHandler(
log_path,
maxBytes=2 * 1024 * 1024,
backupCount=3,
encoding="utf-8",
)
handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
return log_path
class TaskPerformanceTrace:
"""收集一条任务的阶段耗时,并在绑定任务编号后输出记录。"""
def __init__(
self,
*,
monotonic: Callable[[], float] = time.monotonic,
sink: Callable[[dict[str, object]], None] = _default_sink,
) -> None:
self._monotonic = monotonic
self._sink = sink
self._started_at = monotonic()
self._task_id = ""
self._pending: list[tuple[str, int, str]] = []
@property
def task_id(self) -> str:
return self._task_id
def bind_task(self, task_id: str) -> None:
"""绑定稳定任务编号,并输出绑定前缓存的领取阶段记录。"""
value = str(task_id or "").strip()
if not value:
return
if self._task_id and self._task_id != value:
raise RuntimeError("性能计时不能跨任务复用")
self._task_id = value
pending, self._pending = self._pending, []
for operation, duration_ms, result in pending:
self._emit(operation, duration_ms, result)
@contextmanager
def stage(self, operation: str) -> Iterator[None]:
"""记录一个阶段;异常时只写 failed,不记录异常正文。"""
started_at = self._monotonic()
try:
yield
except BaseException:
self.record(operation, self._monotonic() - started_at, "failed")
raise
else:
self.record(operation, self._monotonic() - started_at, "succeeded")
def record(self, operation: str, seconds: float, result: str) -> None:
"""记录白名单阶段数据,负耗时会安全归零。"""
checked_operation = str(operation or "").strip()
checked_result = str(result or "").strip()
if not checked_operation or not checked_result:
raise ValueError("性能操作名和结果分类不能为空")
duration_ms = max(0, round(float(seconds) * 1000))
if self._task_id:
self._emit(checked_operation, duration_ms, checked_result)
else:
self._pending.append(
(checked_operation, duration_ms, checked_result)
)
def checkpoint(self, operation: str, result: str = "succeeded") -> None:
"""记录从本轮调度开始到当前时刻的端到端耗时。"""
self.record(operation, self._monotonic() - self._started_at, result)
@contextmanager
def activate(self) -> Iterator["TaskPerformanceTrace"]:
"""让同一工作线程内的设备和页面适配层取得本计时对象。"""
token = _ACTIVE_TRACE.set(self)
try:
yield self
finally:
_ACTIVE_TRACE.reset(token)
def _emit(self, operation: str, duration_ms: int, result: str) -> None:
self._sink(
{
"task_id": self._task_id,
"operation": operation,
"duration_ms": duration_ms,
"result": result,
}
)
def current_performance_trace() -> Optional[TaskPerformanceTrace]:
"""返回当前工作线程的任务计时对象;普通调用返回 None。"""
return _ACTIVE_TRACE.get()