mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
feat: add low-cardinality observation port
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""运行观测导出器适配器。"""
|
||||
@@ -0,0 +1,49 @@
|
||||
"""可选 OpenTelemetry metrics adapter。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
from typing import Any, Mapping
|
||||
|
||||
from app.runtime.observability import MetricKind, MetricSpec, NoopObservationPort, ObservationPort
|
||||
|
||||
|
||||
class OpenTelemetryObservationPort:
|
||||
"""把内部指标合同映射到可选安装的 OpenTelemetry Metrics API。"""
|
||||
|
||||
def __init__(self, meter: Any) -> None:
|
||||
"""保存 meter,并按名称惰性创建 instrument。"""
|
||||
self._meter = meter
|
||||
self._instruments: dict[str, Any] = {}
|
||||
|
||||
def record(self, spec: MetricSpec, value: float, labels: Mapping[str, str]) -> None:
|
||||
"""按合同类型使用 OTel counter、histogram 或 up/down counter。"""
|
||||
instrument = self._instruments.get(spec.name)
|
||||
if instrument is None:
|
||||
instrument = self._create_instrument(spec)
|
||||
self._instruments[spec.name] = instrument
|
||||
if spec.kind == MetricKind.HISTOGRAM:
|
||||
instrument.record(value, attributes=dict(labels))
|
||||
else:
|
||||
instrument.add(value, attributes=dict(labels))
|
||||
|
||||
def _create_instrument(self, spec: MetricSpec) -> Any:
|
||||
"""为内部指标类型创建对应 OTel instrument。"""
|
||||
normalized = spec.name.replace(".", "_")
|
||||
if spec.kind == MetricKind.HISTOGRAM:
|
||||
return self._meter.create_histogram(normalized)
|
||||
if spec.kind == MetricKind.COUNTER:
|
||||
return self._meter.create_counter(normalized)
|
||||
return self._meter.create_up_down_counter(normalized)
|
||||
|
||||
|
||||
def build_observation_port() -> ObservationPort:
|
||||
"""仅在显式启用且 API 可导入时创建 OTel adapter,否则返回 no-op。"""
|
||||
if os.getenv("MOVIEPILOT_OTEL_METRICS") != "1":
|
||||
return NoopObservationPort()
|
||||
try:
|
||||
metrics = importlib.import_module("opentelemetry.metrics")
|
||||
except ImportError:
|
||||
return NoopObservationPort()
|
||||
return OpenTelemetryObservationPort(metrics.get_meter("moviepilot"))
|
||||
@@ -0,0 +1,58 @@
|
||||
"""HTTP route/status/latency 指标 ASGI 适配器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.runtime.observability import record_metric
|
||||
from starlette.routing import Match
|
||||
|
||||
|
||||
class HttpMetricsMiddleware:
|
||||
"""按路由模板、方法和状态码记录低基数 HTTP 时延。"""
|
||||
|
||||
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
|
||||
started_at = time.perf_counter()
|
||||
status = "500"
|
||||
|
||||
async def send_with_metrics(message: dict) -> None:
|
||||
"""捕获响应状态并原样转发 ASGI 消息。"""
|
||||
nonlocal status
|
||||
if message.get("type") == "http.response.start":
|
||||
status = str(message.get("status", 500))
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self._app(scope, receive, send_with_metrics)
|
||||
finally:
|
||||
route_path = self._resolve_route_template(scope)
|
||||
record_metric(
|
||||
"http.server.duration",
|
||||
time.perf_counter() - started_at,
|
||||
route=route_path,
|
||||
method=str(scope.get("method", "UNKNOWN")),
|
||||
status=status,
|
||||
)
|
||||
|
||||
def _resolve_route_template(self, scope: dict) -> str:
|
||||
"""遍历 ASGI wrapper 找到匹配路由模板,绝不回退到具体请求 path。"""
|
||||
route = scope.get("route")
|
||||
if getattr(route, "path", None):
|
||||
return route.path
|
||||
candidate = self._app
|
||||
while candidate is not None:
|
||||
for registered_route in getattr(candidate, "routes", ()):
|
||||
match, _ = registered_route.matches(scope)
|
||||
if match == Match.FULL:
|
||||
return getattr(registered_route, "path", "unmatched")
|
||||
candidate = getattr(candidate, "app", None)
|
||||
return "unmatched"
|
||||
Reference in New Issue
Block a user