项目文件夹

文件
2026-07-13 12:34:57 +08:00

345 行
11 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""
内置 Prometheus 指标模块(F5
特性:
- 可选依赖:未安装 prometheus-client 时全部 API 退化为 no-op,不报错
- 多 worker 模式(Linux/macOS)自动配置 PROMETHEUS_MULTIPROC_DIR 实现指标聚合
- 懒初始化:本模块 import 时绝不 import prometheus_client,
保证 setup_multiproc_env 的环境变量先于 prometheus_client 初始化生效
- 内置 HTTP 指标中间件与 /metrics 端点(由 Application 在 metrics.enabled 时接入)
配置(conf/config.yaml::
metrics:
enabled: true # 默认 false
path: /metrics # 指标暴露路径
http_metrics: true # 是否启用 HTTP 请求指标中间件
multiproc_dir: null # multiproc 目录(默认系统临时目录下自动生成)
安装: pip install myboot[metrics]
"""
import importlib.util
import os
import re
import sys
import tempfile
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Dict, Iterator, Optional, Sequence, Tuple
from loguru import logger
from .exceptions import MyBootException
# 默认耗时直方图分桶(秒)
DEFAULT_DURATION_BUCKETS = (
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
)
STAGE_HISTOGRAM_NAME = "myboot_stage_duration_seconds"
class MetricsNotAvailableError(MyBootException):
"""prometheus-client 未安装"""
def __init__(self, message: Optional[str] = None):
super().__init__(
message
or "prometheus-client 未安装,指标功能不可用。"
"安装方式: pip install myboot[metrics]",
"METRICS_NOT_AVAILABLE",
)
def is_available() -> bool:
"""prometheus-client 是否已安装(不触发实际 import"""
return importlib.util.find_spec("prometheus_client") is not None
def _coerce_bool(value: Any, default: bool = False) -> bool:
"""布尔/字符串宽容转换(与 get_config_bool 语义一致)"""
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in ("true", "1", "yes", "on")
return bool(value)
def is_enabled(config) -> bool:
"""读取 metrics.enabled 配置(默认 False"""
return _coerce_bool(config.get("metrics.enabled", False))
def _slugify(name: str) -> str:
slug = re.sub(r"[^a-zA-Z0-9_-]+", "_", str(name)).strip("_").lower()
return slug or "app"
def setup_multiproc_env(config, app_name: str) -> None:
"""配置 Prometheus 多进程聚合环境变量
须在 prometheus_client 被 import 之前调用(Application.__init__ 早期)。
仅当满足以下全部条件时生效,否则 no-op:
- metrics.enabled 为真
- prometheus-client 已安装
- server.workers > 1
- 非 Windowswin32 多 worker 为 spawn 模式,multiproc 文件聚合不受支持)
本函数自身绝不 import prometheus_client。
"""
if not is_enabled(config):
return
if not is_available():
return
try:
workers = int(config.get("server.workers", 1) or 1)
except (TypeError, ValueError):
workers = 1
if workers <= 1:
return
if sys.platform == "win32":
logger.warning(
"Windows 多 worker 模式不支持 Prometheus multiproc 聚合,"
"各 worker 将仅暴露本进程指标"
)
return
# 用户已自行设置 → 尊重,不覆盖、不清理
if os.environ.get("PROMETHEUS_MULTIPROC_DIR"):
Path(os.environ["PROMETHEUS_MULTIPROC_DIR"]).mkdir(parents=True, exist_ok=True)
return
if "prometheus_client" in sys.modules:
logger.warning(
"prometheus_client 已在 PROMETHEUS_MULTIPROC_DIR 设置之前被 import,"
"多进程指标聚合可能失效(请避免在应用创建前 import prometheus_client"
)
multiproc_dir = config.get("metrics.multiproc_dir", None)
if multiproc_dir:
target = Path(str(multiproc_dir))
else:
target = Path(tempfile.gettempdir()) / f"myboot_prometheus_{_slugify(app_name)}"
target.mkdir(parents=True, exist_ok=True)
# 仅父进程(MYBOOT_WORKER_ID 尚未设置时)清理陈旧 db 文件
if "MYBOOT_WORKER_ID" not in os.environ:
for stale in target.glob("*.db"):
try:
stale.unlink()
except OSError:
pass
os.environ["PROMETHEUS_MULTIPROC_DIR"] = str(target)
logger.debug(f"Prometheus multiproc 目录: {target}")
def _use_multiproc() -> bool:
return bool(os.environ.get("PROMETHEUS_MULTIPROC_DIR"))
def make_metrics_asgi_app():
"""构建 /metrics ASGI 应用(懒初始化包装器)
首次请求时才 import prometheus_client 并构建真实 app
- multiproc 模式(环境变量已设置)→ MultiProcessCollector 聚合所有 worker
- 否则使用默认全局 REGISTRY
"""
state: Dict[str, Any] = {"app": None}
async def metrics_app(scope, receive, send):
if state["app"] is None:
if not is_available():
raise MetricsNotAvailableError()
from prometheus_client import CollectorRegistry, REGISTRY, make_asgi_app
if _use_multiproc():
from prometheus_client import multiprocess
registry = CollectorRegistry()
multiprocess.MultiProcessCollector(registry)
else:
registry = REGISTRY
state["app"] = make_asgi_app(registry=registry)
await state["app"](scope, receive, send)
return metrics_app
def mark_current_process_dead() -> None:
"""multiproc 模式下标记本进程退出(清理 gauge 残留文件),其余情况 no-op"""
try:
if not _use_multiproc() or not is_available():
return
from prometheus_client import multiprocess
multiprocess.mark_process_dead(os.getpid())
except Exception as e: # 退出路径绝不抛错
logger.debug(f"mark_current_process_dead 失败(已忽略): {e}")
# ==================== 指标工厂(带 no-op 退化) ====================
class _NoopMetric:
"""prometheus-client 未安装时的 no-op 桩对象,支持链式调用"""
def labels(self, *args, **kwargs) -> "_NoopMetric":
return self
def inc(self, *args, **kwargs) -> None:
pass
def observe(self, *args, **kwargs) -> None:
pass
def set(self, *args, **kwargs) -> None:
pass
_NOOP_METRIC = _NoopMetric()
# 进程内指标缓存,防止同名重复注册
_metrics_cache: Dict[str, Any] = {}
def get_counter(name: str, documentation: str, labelnames: Sequence[str] = ()):
"""获取(或创建)Counter;未安装 prometheus-client 时返回 no-op 桩"""
if name in _metrics_cache:
return _metrics_cache[name]
if not is_available():
return _NOOP_METRIC
from prometheus_client import Counter
metric = Counter(name, documentation, list(labelnames))
_metrics_cache[name] = metric
return metric
def get_histogram(
name: str,
documentation: str,
labelnames: Sequence[str] = (),
buckets: Optional[Tuple[float, ...]] = None,
):
"""获取(或创建)Histogram;未安装 prometheus-client 时返回 no-op 桩"""
if name in _metrics_cache:
return _metrics_cache[name]
if not is_available():
return _NOOP_METRIC
from prometheus_client import Histogram
metric = Histogram(
name,
documentation,
list(labelnames),
buckets=buckets or DEFAULT_DURATION_BUCKETS,
)
_metrics_cache[name] = metric
return metric
def observe_stage(stage: str, seconds: float, **labels) -> None:
"""记录某个处理阶段耗时到内置 myboot_stage_duration_seconds 直方图
Args:
stage: 阶段名(如 "recall"、"rank"
seconds: 耗时(秒),负值忽略
**labels: 预留扩展,当前版本忽略额外标签
"""
if seconds < 0:
return
histogram = get_histogram(
STAGE_HISTOGRAM_NAME,
"MyBoot 处理阶段耗时(秒)",
labelnames=("stage",),
)
histogram.labels(stage=stage).observe(seconds)
@contextmanager
def time_stage(stage: str) -> Iterator[None]:
"""上下文管理器:自动计时 with 体并调用 observe_stage"""
t0 = time.perf_counter()
try:
yield
finally:
observe_stage(stage, time.perf_counter() - t0)
# ==================== HTTP 指标中间件 ====================
try: # starlette 是 fastapi 的必备依赖,正常总是可用
from starlette.middleware.base import BaseHTTPMiddleware
except ImportError: # pragma: no cover
BaseHTTPMiddleware = object # type: ignore
class HttpMetricsMiddleware(BaseHTTPMiddleware):
"""HTTP 请求指标中间件
- myboot_http_requests_total{method, path, status} 请求计数
- myboot_http_request_duration_seconds{method, path} 请求耗时直方图
path 标签使用路由模板(如 /items/{id})避免高基数;未匹配路由归入
"unmatched";metrics 自身路径不统计。指标对象懒创建——middleware
import / 构造时不触碰 prometheus_client。
"""
def __init__(self, app, metrics_path: str = "/metrics"):
super().__init__(app)
self.metrics_path = metrics_path
self._requests_total = None
self._request_duration = None
def _ensure_metrics(self) -> None:
if self._requests_total is None:
self._requests_total = get_counter(
"myboot_http_requests_total",
"HTTP 请求总数",
labelnames=("method", "path", "status"),
)
self._request_duration = get_histogram(
"myboot_http_request_duration_seconds",
"HTTP 请求耗时(秒)",
labelnames=("method", "path"),
)
@staticmethod
def _route_path(scope) -> str:
route = scope.get("route")
if route is None:
return "unmatched"
# FastAPI APIRoute → path_format(模板,如 /items/{id});Mount → path
path = getattr(route, "path_format", None) or getattr(route, "path", None)
return path or "unmatched"
async def dispatch(self, request, call_next):
raw_path = request.url.path
if raw_path == self.metrics_path or raw_path.startswith(self.metrics_path + "/"):
return await call_next(request)
t0 = time.perf_counter()
response = await call_next(request)
elapsed = time.perf_counter() - t0
try:
path = self._route_path(request.scope)
self._ensure_metrics()
self._requests_total.labels(
method=request.method, path=path, status=str(response.status_code)
).inc()
self._request_duration.labels(
method=request.method, path=path
).observe(elapsed)
except Exception as e: # 指标采集绝不影响业务请求
logger.debug(f"HTTP 指标采集失败(已忽略): {e}")
return response