feat: add low-cardinality observation port

This commit is contained in:
jxxghp
2026-08-21 22:15:47 +08:00
parent 47f1ff9cb4
commit 43cccb8ff8
12 changed files with 454 additions and 24 deletions
+1
View File
@@ -0,0 +1 @@
"""运行观测导出器适配器。"""
+49
View File
@@ -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"))
+58
View File
@@ -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"
+5
View File
@@ -9,6 +9,8 @@ from starlette.exceptions import HTTPException
from app.api.response import ResponseAPIRoute
from app.adapters.web.correlation import CorrelationIdMiddleware
from app.adapters.web.metrics import HttpMetricsMiddleware
from app.adapters.observability.otel import build_observation_port
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
from app.adapters.web.health import install_health_routes
from app.application.plugin.routes import configure_plugin_routes
@@ -23,6 +25,7 @@ from app.runtime.config import settings
from app.runtime.correlation import get_correlation_id
from app.runtime.localization import LocaleHelper
from app.runtime.log import configure_correlation_id_provider, logger
from app.runtime.observability import configure_observation
from app.schemas.openai import (
AnthropicErrorDetail,
AnthropicErrorResponse,
@@ -294,6 +297,7 @@ def create_app() -> FastAPI:
创建并配置 FastAPI 应用实例。
"""
configure_correlation_id_provider(get_correlation_id)
configure_observation(build_observation_port())
_app = FastAPI(
title=settings.PROJECT_NAME,
version=APP_VERSION,
@@ -321,6 +325,7 @@ def create_app() -> FastAPI:
allow_headers=["*"],
)
_app.add_middleware(CorrelationIdMiddleware)
_app.add_middleware(HttpMetricsMiddleware)
@_app.middleware("http")
async def locale_context_middleware(
+18 -7
View File
@@ -13,6 +13,7 @@ from app.runtime.event.registry import EventRegistry
from app.runtime.execution import run_in_threadpool
from app.runtime.log import logger
from app.runtime.correlation import correlation_scope
from app.runtime.observability import observe_duration
from app.schemas.types import EventType
@@ -147,7 +148,12 @@ class EventDispatcher:
method, binding, class_name, method_name = resolved
with correlation_scope(event.correlation_id):
try:
method(event)
with observe_duration(
"event.handler.duration",
event_type=event.event_type.value,
handler_type="bound" if class_name else "function",
):
method(event)
except Exception as err:
self._error_handler(
event=event,
@@ -165,12 +171,17 @@ class EventDispatcher:
method, binding, class_name, method_name = resolved
with correlation_scope(event.correlation_id):
try:
if inspect.iscoroutinefunction(method):
await method(event)
elif binding.run_sync_in_threadpool or not class_name:
await run_in_threadpool(method, event)
else:
method(event)
with observe_duration(
"event.handler.duration",
event_type=event.event_type.value,
handler_type="bound" if class_name else "function",
):
if inspect.iscoroutinefunction(method):
await method(event)
elif binding.run_sync_in_threadpool or not class_name:
await run_in_threadpool(method, event)
else:
method(event)
except Exception as err:
self._error_handler(
event=event,
+11
View File
@@ -22,6 +22,7 @@ from app.runtime.event.errors import EventErrorNotifier, EventErrorPolicy
from app.runtime.event.registry import EventRegistry
from app.runtime.event.contracts import validate_event_payload
from app.runtime.correlation import get_correlation_id
from app.runtime.observability import record_metric
DEFAULT_EVENT_PRIORITY = 10 # 事件的默认优先级
MIN_EVENT_CONSUMER_THREADS = 1 # 最小事件消费者线程数
@@ -317,6 +318,11 @@ class EventManager(metaclass=Singleton):
"""
logger.debug(f"Triggering broadcast event: {event}")
self.__event_queue.put((event.priority, event))
record_metric(
"event.queue.depth",
self.__event_queue.qsize(),
delivery="broadcast",
)
def __dispatch_chain_event(self, event: Event) -> bool:
"""
@@ -421,6 +427,11 @@ class EventManager(metaclass=Singleton):
while self.__event.is_set():
try:
priority, event = self.__event_queue.get(timeout=rate_limiter.current_wait)
record_metric(
"event.queue.depth",
self.__event_queue.qsize(),
delivery="broadcast",
)
rate_limiter.reset()
self.__dispatch_broadcast_event(event)
except Empty:
+27 -14
View File
@@ -9,6 +9,7 @@ from typing import Any, Protocol
from app.foundation.reflection import ObjectUtils
from app.runtime.execution import run_in_threadpool
from app.runtime.log import logger
from app.runtime.observability import observe_duration
from app.runtime.extensions.module.contracts import (
diagnose_module_callable,
get_module_method_contract,
@@ -68,29 +69,41 @@ class ModuleInvocationDispatcher:
"""先执行插件模块,再按优先级执行宿主模块。"""
contract = get_module_method_contract(method)
logger.debug("模块方法契约:%s -> %s", method, contract.family)
result = self.execute_plugin_modules(method, None, *args, **kwargs)
with observe_duration(
"module.provider.duration", method=method, provider_type="plugin"
):
result = self.execute_plugin_modules(method, None, *args, **kwargs)
if not self.is_valid_empty(result) and not isinstance(result, list):
return result
return self.execute_system_modules(method, result, *args, **kwargs)
with observe_duration(
"module.provider.duration", method=method, provider_type="system"
):
return self.execute_system_modules(method, result, *args, **kwargs)
async def async_dispatch(self, method: str, *args: Any, **kwargs: Any) -> Any:
"""以与同步路径相同的聚合规则执行同步或异步模块方法。"""
contract = get_module_method_contract(method)
logger.debug("异步模块方法契约:%s -> %s", method, contract.family)
result = await self.async_execute_plugin_modules(
method,
None,
*args,
**kwargs,
)
with observe_duration(
"module.provider.duration", method=method, provider_type="plugin"
):
result = await self.async_execute_plugin_modules(
method,
None,
*args,
**kwargs,
)
if not self.is_valid_empty(result) and not isinstance(result, list):
return result
return await self.async_execute_system_modules(
method,
result,
*args,
**kwargs,
)
with observe_duration(
"module.provider.duration", method=method, provider_type="system"
):
return await self.async_execute_system_modules(
method,
result,
*args,
**kwargs,
)
def execute_plugin_modules(
self,
+99
View File
@@ -0,0 +1,99 @@
"""低基数运行指标端口与进程级 Facade。"""
from __future__ import annotations
import time
from contextlib import contextmanager
from dataclasses import dataclass
from enum import StrEnum
from typing import Iterator, Mapping, Protocol
class MetricKind(StrEnum):
"""声明指标采用 counter、histogram 或 gauge 语义。"""
COUNTER = "counter"
HISTOGRAM = "histogram"
GAUGE = "gauge"
@dataclass(frozen=True, slots=True)
class MetricSpec:
"""声明稳定指标名称、类型与允许的低基数标签。"""
name: str
kind: MetricKind
labels: frozenset[str]
METRIC_SPECS = {
spec.name: spec
for spec in (
MetricSpec("http.server.duration", MetricKind.HISTOGRAM, frozenset({"route", "method", "status"})),
MetricSpec("db.pool.wait", MetricKind.HISTOGRAM, frozenset({"backend", "outcome"})),
MetricSpec("db.pool.checked_out", MetricKind.GAUGE, frozenset({"backend"})),
MetricSpec("db.pool.timeout", MetricKind.COUNTER, frozenset({"backend"})),
MetricSpec("event.queue.depth", MetricKind.GAUGE, frozenset({"delivery"})),
MetricSpec("event.handler.duration", MetricKind.HISTOGRAM, frozenset({"event_type", "handler_type", "outcome"})),
MetricSpec("module.provider.duration", MetricKind.HISTOGRAM, frozenset({"method", "provider_type", "outcome"})),
MetricSpec("module.provider.timeout", MetricKind.COUNTER, frozenset({"method", "provider_type"})),
MetricSpec("scheduler.job.duration", MetricKind.HISTOGRAM, frozenset({"owner", "outcome"})),
MetricSpec("scheduler.job.overlap_skip", MetricKind.COUNTER, frozenset({"owner"})),
MetricSpec("scheduler.job.retry", MetricKind.COUNTER, frozenset({"owner"})),
MetricSpec("scheduler.job.dead_letter", MetricKind.COUNTER, frozenset({"owner"})),
MetricSpec("plugin.lifecycle.duration", MetricKind.HISTOGRAM, frozenset({"operation", "outcome"})),
MetricSpec("agent.active_tasks", MetricKind.GAUGE, frozenset({"task_type"})),
MetricSpec("agent.cancel", MetricKind.COUNTER, frozenset({"task_type", "outcome"})),
MetricSpec("agent.provider.duration", MetricKind.HISTOGRAM, frozenset({"provider_type", "outcome"})),
MetricSpec("agent.token_usage", MetricKind.COUNTER, frozenset({"provider_type", "direction"})),
)
}
class ObservationPort(Protocol):
"""Application/runtime 可依赖的最小指标写入端口。"""
def record(self, spec: MetricSpec, value: float, labels: Mapping[str, str]) -> None:
"""记录一个已经通过标签合同校验的指标值。"""
class NoopObservationPort:
"""未安装或未启用 exporter 时完全无副作用的默认实现。"""
def record(self, spec: MetricSpec, value: float, labels: Mapping[str, str]) -> None:
"""接受合法指标但不分配 exporter 资源。"""
_observation_port: ObservationPort = NoopObservationPort()
def configure_observation(port: ObservationPort | None) -> None:
"""由组合根替换进程级端口;None 明确恢复 no-op。"""
global _observation_port
_observation_port = port or NoopObservationPort()
def record_metric(name: str, value: float = 1, **labels: str) -> None:
"""校验名称、类型无关数值和标签白名单后写入当前端口。"""
spec = METRIC_SPECS[name]
unexpected = set(labels) - spec.labels
if unexpected:
raise ValueError(f"指标 {name} 包含未登记标签:{sorted(unexpected)}")
_observation_port.record(spec, float(value), labels)
@contextmanager
def observe_duration(name: str, **labels: str) -> Iterator[None]:
"""记录代码块耗时,并把成功或失败收敛为低基数 outcome。"""
started_at = time.perf_counter()
outcome = "success"
try:
yield
except BaseException:
outcome = "error"
raise
finally:
duration_labels = dict(labels)
if "outcome" in METRIC_SPECS[name].labels:
duration_labels["outcome"] = outcome
record_metric(name, time.perf_counter() - started_at, **duration_labels)
+15
View File
@@ -4,6 +4,7 @@ import hashlib
import inspect
import multiprocessing
import threading
import time
import traceback
from datetime import datetime, timedelta
from typing import Callable, Optional, Dict, Any, List
@@ -46,6 +47,7 @@ from app.runtime.reload import ConfigReloadMixin
from app.foundation.singleton import SingletonClass
from app.runtime.scheduling import TimerUtils
from app.runtime.correlation import call_with_correlation, get_correlation_id
from app.runtime.observability import record_metric
lock = threading.Lock()
SCHEDULER_PROGRESS_PREFIX = "scheduler"
@@ -562,7 +564,12 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
return None
if not JobExecutionState.begin(job, started_at):
logger.warning(f"定时任务 {job_id} - {job.get('name')} 正在运行 ...")
record_metric(
"scheduler.job.overlap_skip",
owner=str(job.get("owner", "unknown")),
)
return None
job["_metric_started_at"] = time.perf_counter()
progress = ProgressHelper(self._get_progress_key(job_id))
progress.start()
progress.update(
@@ -596,6 +603,14 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
job = self._jobs.get(job_id)
if job:
JobExecutionState.finish(job, finished_at, error)
metric_started_at = job.pop("_metric_started_at", None)
if metric_started_at is not None:
record_metric(
"scheduler.job.duration",
time.perf_counter() - metric_started_at,
owner=str(job.get("owner", "unknown")),
outcome="success" if success else "error",
)
job_name = job.get("name") if job else job_id
# 收尾可能发生在事件循环上(__run_coro_job),使用异步进度后端避免阻塞
progress = AsyncProgressHelper(self._get_progress_key(job_id))
@@ -756,6 +756,19 @@ app/scheduler.py # APScheduler 兼容 Facade
OTel 初始化只能位于 Startup/AdapterDomain/Application 只依赖 no-op-capable observation Protocol。插件 ID、用户 ID、媒体标题等高基数字段不得直接作为 metric label。
**实施记录(2026-08-21**
- `app.runtime.observability` 定义单一 `ObservationPort`、默认 no-op、指标类型/目录、标签白名单和统一耗时
作用域;没有 exporter 时所有调用仍可执行,未登记标签在进入 Adapter 前直接拒绝。
- 指标目录覆盖 HTTP、DB pool、Event、Module、Scheduler、Plugin lifecycle 和 Agent 所列能力;标签审计
明确禁止 user/plugin/media/request/job 实例 ID、标题和 URL。首批实际接线覆盖 HTTP route/status/latency、
Event queue/handler、Module provider 与 Scheduler duration/overlap,剩余能力可按相同端口逐个接入。
- `app.adapters.observability.otel` 只在组合根显式读取 `MOVIEPILOT_OTEL_METRICS=1` 后懒加载 OTel API
未安装可选包时稳定回退 no-op,不给核心层增加 SDK 依赖。HTTP Adapter 通过路由匹配输出模板,绝不以原始
request path 充当 label。
- 专项测试覆盖 exporter 缺失、非法标签、全目录高基数审计、成功/失败 outcome、动态 URL 路由模板;
既有 API、Event、Module、Scheduler 与健康探针回归保持通过。
#### ARCH-270:渐进式类型门禁
**目标**:不要求全仓一次通过 mypy/pyright;只保证新 canonical contract 和被治理模块完整类型化。
+19 -3
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6232,
"edge_sha256": "2139dcdcfc12b4f29c732471744d09b0990c51867d4ad227026b00fa947c90d8",
"edge_count": 6244,
"edge_sha256": "f187bdbb5e88ce9a6b2ff559b10e5e6cd5a60d14693484ea17663cebf438cc92",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -106,6 +106,8 @@
"app.adapters.network.doh -> app.runtime.reload",
"app.adapters.network.http -> app.runtime",
"app.adapters.network.http -> app.runtime.correlation",
"app.adapters.observability.otel -> app.runtime",
"app.adapters.observability.otel -> app.runtime.observability",
"app.adapters.system.display -> app.foundation",
"app.adapters.system.display -> app.foundation.singleton",
"app.adapters.system.display -> app.runtime",
@@ -157,6 +159,8 @@
"app.adapters.web.correlation -> app.runtime.correlation",
"app.adapters.web.health -> app.runtime",
"app.adapters.web.health -> app.runtime.health",
"app.adapters.web.metrics -> app.runtime",
"app.adapters.web.metrics -> app.runtime.observability",
"app.adapters.web.security.access -> app.runtime",
"app.adapters.web.security.access -> app.runtime.cache",
"app.adapters.web.security.access -> app.runtime.config",
@@ -3722,9 +3726,12 @@
"app.domain.title -> app.schemas",
"app.domain.title -> app.schemas.types",
"app.factory -> app.adapters",
"app.factory -> app.adapters.observability",
"app.factory -> app.adapters.observability.otel",
"app.factory -> app.adapters.web",
"app.factory -> app.adapters.web.correlation",
"app.factory -> app.adapters.web.health",
"app.factory -> app.adapters.web.metrics",
"app.factory -> app.adapters.web.plugin",
"app.factory -> app.adapters.web.plugin.routes",
"app.factory -> app.adapters.web.security",
@@ -3743,6 +3750,7 @@
"app.factory -> app.runtime.extensions.plugin_manager",
"app.factory -> app.runtime.localization",
"app.factory -> app.runtime.log",
"app.factory -> app.runtime.observability",
"app.factory -> app.schemas",
"app.factory -> app.schemas.mcp",
"app.factory -> app.schemas.openai",
@@ -5365,6 +5373,7 @@
"app.runtime.event.dispatch -> app.runtime.event.registry",
"app.runtime.event.dispatch -> app.runtime.execution",
"app.runtime.event.dispatch -> app.runtime.log",
"app.runtime.event.dispatch -> app.runtime.observability",
"app.runtime.event.dispatch -> app.schemas",
"app.runtime.event.dispatch -> app.schemas.types",
"app.runtime.event.errors -> app.runtime",
@@ -5387,6 +5396,7 @@
"app.runtime.events -> app.runtime.event.errors",
"app.runtime.events -> app.runtime.event.registry",
"app.runtime.events -> app.runtime.log",
"app.runtime.events -> app.runtime.observability",
"app.runtime.events -> app.runtime.rate",
"app.runtime.events -> app.runtime.thread",
"app.runtime.events -> app.schemas",
@@ -5417,6 +5427,7 @@
"app.runtime.extensions.module.dispatcher -> app.runtime.extensions.module",
"app.runtime.extensions.module.dispatcher -> app.runtime.extensions.module.contracts",
"app.runtime.extensions.module.dispatcher -> app.runtime.log",
"app.runtime.extensions.module.dispatcher -> app.runtime.observability",
"app.runtime.extensions.module.dispatcher -> app.schemas",
"app.runtime.extensions.module.dispatcher -> app.schemas.exception",
"app.runtime.extensions.module_manager -> app.foundation",
@@ -5573,6 +5584,7 @@
"app.scheduler -> app.runtime.extensions.service_config",
"app.scheduler -> app.runtime.gc",
"app.scheduler -> app.runtime.log",
"app.scheduler -> app.runtime.observability",
"app.scheduler -> app.runtime.progress",
"app.scheduler -> app.runtime.reload",
"app.scheduler -> app.runtime.scheduling",
@@ -6249,7 +6261,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 778,
"module_count": 782,
"modules": [
"app",
"app.adapters",
@@ -6271,6 +6283,8 @@
"app.adapters.network.doh",
"app.adapters.network.http",
"app.adapters.network.ip",
"app.adapters.observability",
"app.adapters.observability.otel",
"app.adapters.system",
"app.adapters.system.backup",
"app.adapters.system.backup.database",
@@ -6291,6 +6305,7 @@
"app.adapters.web",
"app.adapters.web.correlation",
"app.adapters.web.health",
"app.adapters.web.metrics",
"app.adapters.web.plugin",
"app.adapters.web.plugin.routes",
"app.adapters.web.security",
@@ -6922,6 +6937,7 @@
"app.runtime.localization",
"app.runtime.log",
"app.runtime.managed_resources",
"app.runtime.observability",
"app.runtime.progress",
"app.runtime.rate",
"app.runtime.reload",
+139
View File
@@ -0,0 +1,139 @@
"""低基数指标合同、no-op 与 HTTP adapter 测试。"""
from dataclasses import dataclass, field
from typing import Mapping
import httpx
import pytest
from starlette.applications import Starlette
from starlette.responses import PlainTextResponse
from starlette.routing import Route
from app.adapters.observability import otel
from app.adapters.web.metrics import HttpMetricsMiddleware
from app.runtime.observability import (
METRIC_SPECS,
MetricSpec,
NoopObservationPort,
configure_observation,
observe_duration,
record_metric,
)
@dataclass
class RecordingObservationPort:
"""测试用端口,保存已经通过核心标签校验的写入。"""
records: list[tuple[MetricSpec, float, Mapping[str, str]]] = field(
default_factory=list
)
def record(
self, spec: MetricSpec, value: float, labels: Mapping[str, str]
) -> None:
"""追加一条不可变测试快照。"""
self.records.append((spec, value, dict(labels)))
@pytest.fixture(autouse=True)
def _reset_observation_port():
"""避免进程级观测端口在用例间泄漏。"""
configure_observation(None)
yield
configure_observation(None)
def test_noop_port_accepts_registered_metric_without_exporter() -> None:
"""未安装 exporter 时记录合法指标必须完全可用。"""
configure_observation(NoopObservationPort())
record_metric(
"http.server.duration",
0.1,
route="/health/live",
method="GET",
status="200",
)
def test_metric_catalog_contains_no_high_cardinality_labels() -> None:
"""整个指标目录不得登记用户、插件、媒体、URL 或请求实例标签。"""
forbidden = {
"user_id",
"plugin_id",
"media_id",
"media_title",
"url",
"request_id",
"job_id",
}
assert METRIC_SPECS
assert all(not (spec.labels & forbidden) for spec in METRIC_SPECS.values())
def test_unregistered_label_is_rejected_before_adapter() -> None:
"""调用方不能绕过目录向 exporter 注入高基数标签。"""
with pytest.raises(ValueError, match="未登记标签"):
record_metric(
"scheduler.job.duration",
1,
owner="plugin",
outcome="success",
job_id="dynamic-123",
)
def test_duration_records_success_and_error_outcomes() -> None:
"""统一计时器把正常和异常路径收敛为有限 outcome。"""
port = RecordingObservationPort()
configure_observation(port)
with observe_duration(
"module.provider.duration", method="recognize_media", provider_type="system"
):
pass
with pytest.raises(RuntimeError):
with observe_duration(
"module.provider.duration",
method="recognize_media",
provider_type="plugin",
):
raise RuntimeError("failed")
assert [record[2]["outcome"] for record in port.records] == ["success", "error"]
@pytest.mark.asyncio
async def test_http_metrics_use_route_template_not_request_url() -> None:
"""HTTP 指标使用路由模板,不能把具体资源 ID 变成 label。"""
port = RecordingObservationPort()
configure_observation(port)
async def item(_request):
"""返回固定测试响应。"""
return PlainTextResponse("ok")
app = Starlette(routes=[Route("/items/{item_id}", item)])
app.add_middleware(HttpMetricsMiddleware)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.get("/items/secret-item")
assert response.status_code == 200
_, _, labels = port.records[-1]
assert labels == {"route": "/items/{item_id}", "method": "GET", "status": "200"}
def test_optional_otel_adapter_falls_back_to_noop(monkeypatch) -> None:
"""显式启用但未安装 OTel API 时启动仍返回 no-op。"""
monkeypatch.setenv("MOVIEPILOT_OTEL_METRICS", "1")
def missing(_name: str):
"""模拟可选依赖不存在。"""
raise ImportError("missing")
monkeypatch.setattr(otel.importlib, "import_module", missing)
assert isinstance(otel.build_observation_port(), NoopObservationPort)