111 lines
4.0 KiB
Python
111 lines
4.0 KiB
Python
"""接口指标埋点:有界事件队列 + 纯 ASGI 中间件。
|
|
|
|
每个 HTTP 请求测总耗时、抓路由模板 + 状态码,非阻塞塞进有界队列;由 observe_worker
|
|
后台批量上报到 OpenObserve。请求路径上无任何 I/O。未配置观测时中间件直接透传。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import time
|
|
|
|
from starlette.routing import Match
|
|
|
|
from app.core.config import settings
|
|
|
|
# 不采集的路径(纯噪音):健康检查。
|
|
_SKIP_PATHS = frozenset({"/health"})
|
|
# 未匹配路由(404/扫描器)归一到此,防维度爆炸。
|
|
_UNMATCHED = "__unmatched__"
|
|
# service 字段:与 logging.py 同源(LOG_SERVICE_NAME),默认 app-server。
|
|
_SERVICE = os.getenv("LOG_SERVICE_NAME", "app-server")
|
|
|
|
# 有界事件队列(懒创建,见 get_queue):首次取用时在运行中的 loop 里建,避免 import 期
|
|
# 无 loop 的边角问题;put_nowait/get_nowait 不需运行中的 loop → 可在无 loop 下测试。
|
|
_queue: asyncio.Queue[dict] | None = None
|
|
# 队列满时的丢弃计数,worker 定期取出打日志。
|
|
_dropped = 0
|
|
|
|
|
|
def get_queue() -> asyncio.Queue[dict]:
|
|
"""返回全局有界事件队列(懒创建)。测试可 monkeypatch 模块级 _queue 换成小队列。"""
|
|
global _queue
|
|
if _queue is None:
|
|
_queue = asyncio.Queue(maxsize=settings.OBSERVE_QUEUE_MAX)
|
|
return _queue
|
|
|
|
|
|
def take_dropped() -> int:
|
|
"""取出并清零累计丢弃数(供 worker 打点)。"""
|
|
global _dropped
|
|
n, _dropped = _dropped, 0
|
|
return n
|
|
|
|
|
|
def record_event(event: dict) -> None:
|
|
"""非阻塞入队;队列满则丢弃当前事件并计数。永不抛异常、永不阻塞请求。"""
|
|
global _dropped
|
|
try:
|
|
get_queue().put_nowait(event)
|
|
except asyncio.QueueFull:
|
|
_dropped += 1
|
|
|
|
|
|
def _resolve_route(scope) -> str:
|
|
"""从 scope 取路由模板(如 /things/{tid})。优先 scope['route'](现代 Starlette
|
|
路由后写入);取不到则手动匹配一次(老版本兜底);仍无 → __unmatched__(404/扫描器)。"""
|
|
route = scope.get("route")
|
|
path = getattr(route, "path", None)
|
|
if path:
|
|
return path
|
|
app_ = scope.get("app")
|
|
router = getattr(app_, "router", None)
|
|
for candidate in getattr(router, "routes", []):
|
|
try:
|
|
match, _ = candidate.matches(scope)
|
|
except Exception: # noqa: BLE001 - 匹配兜底,任一路由异常不影响整体
|
|
continue
|
|
if match == Match.FULL and getattr(candidate, "path", None):
|
|
return candidate.path
|
|
return _UNMATCHED
|
|
|
|
|
|
class RequestMetricsMiddleware:
|
|
"""纯 ASGI 中间件:测每个 http 请求耗时,记 method/route/status/duration。
|
|
|
|
放在最外层(main.py 里 CORS 之后 add),测到含 CORS 的完整耗时。未配置观测 → 透传。
|
|
"""
|
|
|
|
def __init__(self, app) -> None:
|
|
self.app = app
|
|
|
|
async def __call__(self, scope, receive, send) -> None:
|
|
if scope["type"] != "http" or not settings.observe_configured:
|
|
await self.app(scope, receive, send)
|
|
return
|
|
if scope.get("path") in _SKIP_PATHS:
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
start = time.perf_counter()
|
|
status_holder = {"status": 500} # 下游异常未产出 response 时兜底 500
|
|
|
|
async def send_wrapper(message) -> None:
|
|
if message["type"] == "http.response.start":
|
|
status_holder["status"] = message["status"]
|
|
await send(message)
|
|
|
|
try:
|
|
await self.app(scope, receive, send_wrapper)
|
|
finally:
|
|
duration_ms = (time.perf_counter() - start) * 1000.0
|
|
record_event({
|
|
"_timestamp": int(time.time() * 1_000_000), # µs,OpenObserve 时间列
|
|
"service": _SERVICE,
|
|
"env": settings.APP_ENV,
|
|
"method": scope.get("method", ""),
|
|
"route": _resolve_route(scope),
|
|
"status": status_holder["status"],
|
|
"duration_ms": round(duration_ms, 3),
|
|
})
|