litefs.middleware.prometheus 源代码

#!/usr/bin/env python
# coding: utf-8

"""
Prometheus 监控中间件

采集 HTTP 请求计数、延迟直方图、错误率,以及数据库连接池状态与缓存命中情况,
并通过 ``/metrics`` 端点以 Prometheus 文本格式暴露。

设计要点:
- prometheus-client 为可选依赖,未安装时抛出
  :class:`~litefs.metrics.PrometheusNotAvailable` 并给出安装指引。
- ``/metrics`` 端点直接返回 ``(status, headers, content)`` 三元组,
  以兼容框架的 ``_parse_result_tuple`` 解析逻辑。
- 路由标签使用**路由模板**(如 ``/user/{id}``)而非原始路径,
  保证指标基数可控。
"""

import time
from typing import Any, Optional, Sequence

from ..metrics import UNMATCHED_ROUTE, MetricsRegistry, PrometheusNotAvailable
from .base import Middleware

# 请求处理器上保存计时起点与去重标记的属性名
_START_TIME_ATTR = "_prometheus_start_time"
_RECORDED_ATTR = "_prometheus_recorded"
# 指标端点自身的请求不计入请求指标,避免自监控噪声
_SKIP_ATTR = "_prometheus_skip"


[文档] class PrometheusMiddleware(Middleware): """ Prometheus 监控中间件 挂载方式:: app.add_middleware(PrometheusMiddleware) 指定暴露路径与命名空间:: app.add_middleware(PrometheusMiddleware, path='/internal/metrics', namespace='myapp') """ def __init__( self, app, path: str = "/metrics", namespace: str = "litefs", buckets: Optional[Sequence[float]] = None, exclude_paths: Optional[Sequence[str]] = None, ) -> None: """ 初始化 Prometheus 监控中间件 Args: app: Litefs 应用实例 path: 指标暴露路径,默认为 /metrics namespace: 指标名称前缀,默认为 litefs buckets: HTTP 延迟直方图分桶(秒),None 使用默认值 exclude_paths: 不采集指标的路径前缀列表 Raises: PrometheusNotAvailable: 未安装 prometheus-client 时抛出 """ super().__init__(app) self.path = path self.exclude_paths = tuple(exclude_paths or ()) self.registry = MetricsRegistry( namespace=namespace, buckets=tuple(buckets) if buckets else None, ) # ==================== 中间件接口 ====================
[文档] def process_request(self, request_handler): """ 处理请求,记录起始时间并响应指标端点 Args: request_handler: 请求处理器实例 Returns: None: 继续处理请求 三元组: 指标端点命中时直接返回响应 """ self._mark_start_time(request_handler) path = request_handler._environ.get("PATH_INFO", "") method = request_handler._environ.get("REQUEST_METHOD", "GET") if method == "GET" and path == self.path: # 指标端点自身不计入请求指标,避免自监控噪声 setattr(request_handler, _SKIP_ATTR, True) return self._render_metrics() return None
[文档] def process_response(self, request_handler, response): """ 处理响应,记录请求指标 Args: request_handler: 请求处理器实例 response: 响应数据 Returns: 未修改的响应数据 """ if getattr(request_handler, _RECORDED_ATTR, False): return response if getattr(request_handler, _SKIP_ATTR, False): return response extracted = self._extract_response(request_handler, response) if extracted is None: return response status_code, headers, content = extracted duration = self._elapsed(request_handler) response_size = self._content_length(headers, content) method = request_handler._environ.get("REQUEST_METHOD", "GET") route_path = getattr(request_handler, "route_path", None) or UNMATCHED_ROUTE if self._is_excluded(route_path): return response self.registry.observe_request( method=method, path=route_path, status_code=status_code, duration=duration, response_size=response_size, ) setattr(request_handler, _RECORDED_ATTR, True) return response
[文档] def process_exception(self, request_handler, exception): """ 处理异常,补记一次错误指标 业务异常通常在路由执行阶段被框架捕获并转为 500 响应, 此处仅作为兜底(如 ASGI 的 HttpError),避免指标重复计数。 Args: request_handler: 请求处理器实例 exception: 异常对象 Returns: None: 继续抛出异常 """ if getattr(request_handler, _RECORDED_ATTR, False): return None if getattr(request_handler, _SKIP_ATTR, False): return None duration = self._elapsed(request_handler) method = request_handler._environ.get("REQUEST_METHOD", "GET") route_path = getattr(request_handler, "route_path", None) or UNMATCHED_ROUTE if not self._is_excluded(route_path): self.registry.observe_request( method=method, path=route_path, status_code=500, duration=duration, response_size=0, ) setattr(request_handler, _RECORDED_ATTR, True) return None
# ==================== 异步接口 ====================
[文档] async def async_process_request(self, request_handler): """ 异步版本的处理请求 Args: request_handler: 请求处理器实例 Returns: None: 继续处理请求 三元组: 指标端点命中时直接返回响应 """ return self.process_request(request_handler)
[文档] async def async_process_response(self, request_handler, response): """ 异步版本的处理响应 Args: request_handler: 请求处理器实例 response: 响应数据 Returns: 未修改的响应数据 """ return self.process_response(request_handler, response)
[文档] async def async_process_exception(self, request_handler, exception): """ 异步版本的处理异常 Args: request_handler: 请求处理器实例 exception: 异常对象 Returns: None: 继续抛出异常 """ return self.process_exception(request_handler, exception)
# ==================== 内部方法 ==================== def _mark_start_time(self, request_handler) -> None: """ 记录请求起始时间 框架在 WSGI / ASGI 路径下均会重复调用 ``process_request``, 因此这里只在首次调用时写入,保证计时起点不被覆盖。 Args: request_handler: 请求处理器实例 """ if getattr(request_handler, _START_TIME_ATTR, None) is None: setattr(request_handler, _START_TIME_ATTR, time.perf_counter()) def _elapsed(self, request_handler) -> float: """ 计算请求耗时 Args: request_handler: 请求处理器实例 Returns: 耗时(秒),无法确定时返回 0 """ start = getattr(request_handler, _START_TIME_ATTR, None) if start is None: return 0.0 return max(0.0, time.perf_counter() - float(start)) def _extract_response(self, request_handler, response): """ 从响应对象中提取状态码、响应头与响应体 委托给基类的 ``_extract_response_parts``,兼容 WSGI / ASGI 三元组 与原生 Socket 响应体两种形态。 Args: request_handler: 请求处理器实例 response: 响应数据 Returns: (状态码, 响应头列表, 响应体) 三元组,无法解析时返回 None """ return self._extract_response_parts(request_handler, response) def _is_excluded(self, route_path: str) -> bool: """ 判断路径是否在排除列表中 Args: route_path: 路由模板 Returns: 是否排除采集 """ if not self.exclude_paths: return False return any(route_path.startswith(prefix) for prefix in self.exclude_paths) def _content_length(self, headers: Any, content: Any) -> int: """ 计算响应体大小 优先读取 Content-Length 响应头;缺失时按内容类型估算。 Args: headers: 响应头列表 content: 响应内容 Returns: 响应体大小(字节),无法确定时返回 0 """ if isinstance(headers, (list, tuple)): for header in headers: try: key, value = header except (TypeError, ValueError): continue if str(key).lower() == "content-length": try: return int(value) except (TypeError, ValueError): break if isinstance(content, str): return len(content.encode("utf-8")) if isinstance(content, (bytes, bytearray)): return len(content) return 0 def _render_metrics(self) -> Any: """ 渲染指标内容 Returns: (status, headers, content) 三元组 """ try: content = self.registry.render() except PrometheusNotAvailable: raise except Exception as exc: # pragma: no cover - 防御性保护 content = f"# 指标采集失败: {exc}\n" return ( "200 OK", [("Content-Type", self.registry.content_type)], content, )
__all__ = [ "PrometheusMiddleware", ]