添加统计请求的功能
This commit is contained in:
@@ -17,6 +17,10 @@ password.txt
|
||||
worker.pid
|
||||
worker.log
|
||||
|
||||
# 请求日志(运行时生成,不入 git)
|
||||
gateway/request_log.jsonl
|
||||
gateway/request_log.jsonl.1
|
||||
|
||||
# SegFormer 模型权重(~323MB,体积过大,不入 git,见 OFFLINE_ASSETS.md)
|
||||
hairline/models/face-parsing/model.safetensors
|
||||
|
||||
|
||||
+267
-1
@@ -15,10 +15,15 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, File, Form, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from gateway.config import load_config
|
||||
from gateway.logging_middleware import (
|
||||
get_stats,
|
||||
init_logging as _init_req_logging,
|
||||
request_logging_middleware,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 日志
|
||||
@@ -43,6 +48,9 @@ async def lifespan(app: FastAPI):
|
||||
cfg = load_config()
|
||||
logger.info("网关启动中... workers=%s", cfg["workers"])
|
||||
|
||||
# 初始化请求日志
|
||||
_init_req_logging(cfg)
|
||||
|
||||
# 初始化健康池(阶段二实现)
|
||||
try:
|
||||
from gateway.pool import init_pool, shutdown_pool as _pool_shutdown
|
||||
@@ -105,6 +113,9 @@ static_root.mkdir(parents=True, exist_ok=True)
|
||||
(static_root / "annotations").mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/static", StaticFiles(directory=str(static_root)), name="static")
|
||||
|
||||
# 请求日志中间件(在所有路由之前,静态文件之后)
|
||||
app.middleware("http")(request_logging_middleware)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 健康检查(网关自身)
|
||||
@@ -189,6 +200,7 @@ async def index():
|
||||
"service": "旷视五接口 — 网关",
|
||||
"version": "0.1.0",
|
||||
"docs": "/docs",
|
||||
"stats": "/admin/stats",
|
||||
"integration_guide": "/static/integration.html",
|
||||
"test_pages": {
|
||||
"if1_measure": "/static/test_interface1.html",
|
||||
@@ -202,6 +214,260 @@ async def index():
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 请求统计仪表盘 HTML
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STATS_PAGE_HTML = """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>网关请求统计</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||
.container { max-width: 1300px; margin: 0 auto; padding: 24px; }
|
||||
h1 { font-size: 22px; margin-bottom: 4px; }
|
||||
.subtitle { color: #888; font-size: 13px; margin-bottom: 20px; }
|
||||
.nav { margin-bottom: 20px; }
|
||||
.nav a { color: #2563eb; text-decoration: none; font-size: 13px; }
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
|
||||
/* 汇总卡片 */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin-bottom: 24px; }
|
||||
.stat-card { background: #fff; border-radius: 12px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
.stat-card .value { font-size: 28px; font-weight: 700; color: #111827; }
|
||||
.stat-card .label { font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
|
||||
.stat-card.ok .value { color: #059669; }
|
||||
.stat-card.warn .value { color: #d97706; }
|
||||
|
||||
/* 表格 */
|
||||
.section { margin-bottom: 24px; }
|
||||
.section h2 { font-size: 16px; margin-bottom: 10px; color: #374151; }
|
||||
.table-wrap { background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 9px 14px; text-align: left; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
|
||||
th { background: #f8fafc; font-weight: 700; color: #475569; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; white-space: nowrap; }
|
||||
tr:hover td { background: #fafbfc; }
|
||||
td.mono { font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; }
|
||||
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
|
||||
.badge-ok { background: #d1fae5; color: #065f46; }
|
||||
.badge-err { background: #fee2e2; color: #991b1b; }
|
||||
.badge-other { background: #f3f4f6; color: #6b7280; }
|
||||
.duration-fast { color: #059669; }
|
||||
.duration-mid { color: #d97706; }
|
||||
.duration-slow { color: #dc2626; }
|
||||
|
||||
.footer { text-align: right; font-size: 12px; color: #9ca3af; margin-top: 20px; }
|
||||
.auto-refresh { display: flex; align-items: center; gap: 8px; }
|
||||
.auto-refresh input { accent-color: #2563eb; }
|
||||
.empty { text-align: center; padding: 40px; color: #9ca3af; font-size: 14px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
th, td { padding: 6px 8px; font-size: 12px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>📊 网关请求统计</h1>
|
||||
<p class="subtitle">实时请求监控 | 每 3 秒自动刷新</p>
|
||||
<div class="nav">
|
||||
<a href="/">← 返回首页</a> |
|
||||
<a href="/docs">API 文档</a> |
|
||||
<a href="/static/integration.html">接入指南</a>
|
||||
</div>
|
||||
|
||||
<!-- 汇总卡片 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="value" id="totalCount">—</div><div class="label">请求总数</div></div>
|
||||
<div class="stat-card ok"><div class="value" id="successRate">—</div><div class="label">成功率(code=0)</div></div>
|
||||
<div class="stat-card"><div class="value" id="avgTime">—</div><div class="label">平均响应时间</div></div>
|
||||
<div class="stat-card"><div class="value" id="minTime">—</div><div class="label">最短响应</div></div>
|
||||
<div class="stat-card warn"><div class="value" id="maxTime">—</div><div class="label">最长响应</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 按接口 -->
|
||||
<div class="section">
|
||||
<h2>📋 按接口统计</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>路径</th><th>请求数</th><th>平均耗时</th><th>最大耗时</th><th>成功率</th></tr></thead>
|
||||
<tbody id="endpointTable"><tr><td class="empty" colspan="5">暂无数据</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按 Worker -->
|
||||
<div class="section">
|
||||
<h2>🖥️ 按 GPU Worker 统计</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Worker</th><th>请求数</th><th>平均耗时</th><th>成功率</th></tr></thead>
|
||||
<tbody id="workerTable"><tr><td class="empty" colspan="4">暂无数据</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近请求 -->
|
||||
<div class="section">
|
||||
<h2>🕐 最近请求(最新 100 条)</h2>
|
||||
<div class="table-wrap" style="max-height:600px;overflow:auto;">
|
||||
<table>
|
||||
<thead><tr><th>时间</th><th>方法</th><th>路径</th><th>Worker</th><th>HTTP</th><th>业务码</th><th>耗时</th><th>客户端 IP</th></tr></thead>
|
||||
<tbody id="recentTable"><tr><td class="empty" colspan="8">暂无数据</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<label class="auto-refresh">
|
||||
<input type="checkbox" id="autoRefresh" checked onchange="toggleAutoRefresh()"> 自动刷新(3s)
|
||||
</label>
|
||||
<span style="margin-left:16px" id="lastUpdated">加载中…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let _timer = null;
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (ms < 1000) return ms.toFixed(1) + 'ms';
|
||||
if (ms < 60000) return (ms / 1000).toFixed(2) + 's';
|
||||
return (ms / 60000).toFixed(1) + 'min';
|
||||
}
|
||||
|
||||
function durationClass(ms) {
|
||||
if (ms < 500) return 'duration-fast';
|
||||
if (ms < 2000) return 'duration-mid';
|
||||
return 'duration-slow';
|
||||
}
|
||||
|
||||
function badgeClass(code) {
|
||||
if (code === 0) return 'badge-ok';
|
||||
if (code !== null && code !== undefined && code !== 0) return 'badge-err';
|
||||
return 'badge-other';
|
||||
}
|
||||
|
||||
function badgeText(code) {
|
||||
if (code === 0) return 'OK';
|
||||
if (code !== null && code !== undefined) return 'ERR ' + code;
|
||||
return '—';
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await fetch('/admin/stats/json');
|
||||
const data = await r.json();
|
||||
const s = data.summary;
|
||||
|
||||
document.getElementById('totalCount').textContent = s.total.toLocaleString();
|
||||
document.getElementById('successRate').textContent = s.success_rate + '%';
|
||||
document.getElementById('avgTime').textContent = formatDuration(s.avg_duration_ms);
|
||||
document.getElementById('minTime').textContent = formatDuration(s.min_duration_ms);
|
||||
document.getElementById('maxTime').textContent = formatDuration(s.max_duration_ms);
|
||||
|
||||
// 按接口
|
||||
let ehtml = '';
|
||||
if (data.endpoints.length === 0) {
|
||||
ehtml = '<tr><td class="empty" colspan="5">暂无数据</td></tr>';
|
||||
} else {
|
||||
data.endpoints.forEach(function(e) {
|
||||
ehtml += '<tr>' +
|
||||
'<td class="mono">' + e.path + '</td>' +
|
||||
'<td>' + e.count + '</td>' +
|
||||
'<td class="' + durationClass(e.avg_duration_ms) + '">' + formatDuration(e.avg_duration_ms) + '</td>' +
|
||||
'<td>' + formatDuration(e.max_duration_ms) + '</td>' +
|
||||
'<td><span class="badge ' + badgeClass(0) + '" style="opacity:' + (e.success_rate / 100) + '">' + e.success_rate + '%</span></td>' +
|
||||
'</tr>';
|
||||
});
|
||||
}
|
||||
document.getElementById('endpointTable').innerHTML = ehtml;
|
||||
|
||||
// 按 Worker
|
||||
let whtml = '';
|
||||
if (!data.workers || data.workers.length === 0) {
|
||||
whtml = '<tr><td class="empty" colspan="4">暂无数据</td></tr>';
|
||||
} else {
|
||||
data.workers.forEach(function(w) {
|
||||
whtml += '<tr>' +
|
||||
'<td class="mono">' + w.worker + '</td>' +
|
||||
'<td>' + w.count + '</td>' +
|
||||
'<td class="' + durationClass(w.avg_duration_ms) + '">' + formatDuration(w.avg_duration_ms) + '</td>' +
|
||||
'<td><span class="badge ' + badgeClass(0) + '" style="opacity:' + (w.success_rate / 100) + '">' + w.success_rate + '%</span></td>' +
|
||||
'</tr>';
|
||||
});
|
||||
}
|
||||
document.getElementById('workerTable').innerHTML = whtml;
|
||||
|
||||
// 最近请求
|
||||
let rhtml = '';
|
||||
if (data.recent.length === 0) {
|
||||
rhtml = '<tr><td class="empty" colspan="8">暂无数据</td></tr>';
|
||||
} else {
|
||||
data.recent.forEach(function(entry) {
|
||||
var ts = entry.timestamp.replace('T', ' ').substring(0, 23);
|
||||
var workerDisplay = entry.worker || '—';
|
||||
// 短 worker 显示:只取主机部分
|
||||
if (workerDisplay.length > 30) {
|
||||
workerDisplay = workerDisplay.replace(/^https?:\/\//, '').substring(0, 28) + '…';
|
||||
}
|
||||
rhtml += '<tr>' +
|
||||
'<td class="mono">' + ts + '</td>' +
|
||||
'<td>' + entry.method + '</td>' +
|
||||
'<td class="mono">' + entry.path + '</td>' +
|
||||
'<td class="mono" style="font-size:11px">' + workerDisplay + '</td>' +
|
||||
'<td>' + entry.status_code + '</td>' +
|
||||
'<td><span class="badge ' + badgeClass(entry.response_code) + '">' + badgeText(entry.response_code) + '</span></td>' +
|
||||
'<td class="' + durationClass(entry.duration_ms) + '">' + formatDuration(entry.duration_ms) + '</td>' +
|
||||
'<td class="mono">' + entry.client_ip + '</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
}
|
||||
document.getElementById('recentTable').innerHTML = rhtml;
|
||||
|
||||
document.getElementById('lastUpdated').textContent = '最后更新: ' + new Date().toLocaleTimeString();
|
||||
} catch(err) {
|
||||
document.getElementById('lastUpdated').textContent = '加载失败: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
var checked = document.getElementById('autoRefresh').checked;
|
||||
if (checked) {
|
||||
_timer = setInterval(refresh, 3000);
|
||||
} else {
|
||||
clearInterval(_timer);
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
_timer = setInterval(refresh, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 请求统计页面
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/admin/stats", include_in_schema=False)
|
||||
async def admin_stats():
|
||||
"""请求统计仪表盘(HTML 页面)。"""
|
||||
return HTMLResponse(content=_STATS_PAGE_HTML)
|
||||
|
||||
|
||||
@app.get("/admin/stats/json", include_in_schema=False)
|
||||
async def admin_stats_json():
|
||||
"""请求统计数据(JSON,供页面轮询)。"""
|
||||
return get_stats()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 代理路由
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -33,6 +33,13 @@ DEFAULTS = {
|
||||
"interval_minutes": 60,
|
||||
"max_age_hours": 24,
|
||||
},
|
||||
"request_log": {
|
||||
"enabled": True,
|
||||
"log_file": "gateway/request_log.jsonl",
|
||||
"buffer_size": 2000,
|
||||
"max_file_lines": 10000,
|
||||
"max_file_age_days": 7,
|
||||
},
|
||||
}
|
||||
|
||||
_config_cache = None
|
||||
|
||||
@@ -162,6 +162,8 @@ async def proxy_request(request: Request, path: str) -> JSONResponse:
|
||||
worker = None
|
||||
try:
|
||||
worker = await acquire_worker(cfg)
|
||||
# 记录当前使用的 worker,供日志中间件读取
|
||||
request.state.worker_url = worker.url
|
||||
except NoWorkerAvailable:
|
||||
logger.warning("无可用 worker,返回 1007")
|
||||
return JSONResponse(
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"""请求日志中间件:为每个请求记录时间、路径、耗时等,并提供统计查询。
|
||||
|
||||
- 内存环形缓冲区(最近 N 条)
|
||||
- JSON Lines 文件持久化(自动轮转)
|
||||
- ASGI 中间件透明捕获请求/响应
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger("gateway.logging_middleware")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 数据结构
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestLogEntry:
|
||||
"""单条请求日志。"""
|
||||
timestamp: str # ISO-8601
|
||||
method: str
|
||||
path: str
|
||||
status_code: int
|
||||
duration_ms: float
|
||||
client_ip: str
|
||||
worker: str = "" # 处理请求的 worker URL(空串表示网关本地处理)
|
||||
response_code: Optional[int] = None # 响应 JSON 中的 code 字段
|
||||
request_id: Optional[str] = None # 响应 JSON 中的 request_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 环形缓冲区
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RingBuffer:
|
||||
"""固定大小的环形缓冲区,线程安全。"""
|
||||
|
||||
def __init__(self, maxlen: int = 2000):
|
||||
self._deque: deque = deque(maxlen=maxlen)
|
||||
|
||||
def append(self, entry: RequestLogEntry) -> None:
|
||||
self._deque.append(entry)
|
||||
|
||||
def snapshot(self) -> List[RequestLogEntry]:
|
||||
"""返回当前缓冲区副本(最新在前)。"""
|
||||
return list(reversed(self._deque))
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._deque)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON Lines 文件写入(含轮转)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LogFileWriter:
|
||||
"""追加写入 JSON Lines 日志文件,自动按行数 / 天数轮转。
|
||||
|
||||
轮转策略:保留 1 个备份 (.jsonl.1),不保留更多历史。
|
||||
"""
|
||||
|
||||
def __init__(self, filepath: str, max_lines: int = 10000, max_age_days: int = 7):
|
||||
self.filepath = Path(filepath)
|
||||
self.max_lines = max_lines
|
||||
self.max_age_seconds = max_age_days * 86400
|
||||
|
||||
def write(self, entry: RequestLogEntry) -> None:
|
||||
try:
|
||||
self._maybe_rotate()
|
||||
self.filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
line = json.dumps(asdict(entry), ensure_ascii=False) + "\n"
|
||||
with open(self.filepath, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
except Exception:
|
||||
logger.warning("写入请求日志失败", exc_info=True)
|
||||
|
||||
def _maybe_rotate(self) -> None:
|
||||
if not self.filepath.exists():
|
||||
return
|
||||
|
||||
# 按天数轮转
|
||||
mtime = self.filepath.stat().st_mtime
|
||||
if time.time() - mtime > self.max_age_seconds:
|
||||
self._rotate()
|
||||
return
|
||||
|
||||
# 按行数轮转
|
||||
try:
|
||||
with open(self.filepath, "r", encoding="utf-8") as f:
|
||||
count = sum(1 for _ in f)
|
||||
if count >= self.max_lines:
|
||||
self._rotate()
|
||||
except Exception:
|
||||
pass # 读不到就算了,下次再说
|
||||
|
||||
def _rotate(self) -> None:
|
||||
backup = self.filepath.with_suffix(".jsonl.1")
|
||||
if backup.exists():
|
||||
backup.unlink()
|
||||
try:
|
||||
self.filepath.rename(backup)
|
||||
logger.info("请求日志已轮转: %s → %s", self.filepath.name, backup.name)
|
||||
except Exception:
|
||||
logger.warning("日志轮转失败", exc_info=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 模块级全局状态(由 init_logging 初始化)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_buffer: Optional[RingBuffer] = None
|
||||
_writer: Optional[LogFileWriter] = None
|
||||
|
||||
|
||||
def init_logging(cfg: dict) -> None:
|
||||
"""初始化日志缓冲区与文件写入器。"""
|
||||
global _buffer, _writer
|
||||
|
||||
log_cfg = cfg.get("request_log", {})
|
||||
if not log_cfg.get("enabled", True):
|
||||
logger.info("请求日志已禁用")
|
||||
return
|
||||
|
||||
log_file = log_cfg.get("log_file", "gateway/request_log.jsonl")
|
||||
log_path = Path(log_file)
|
||||
if not log_path.is_absolute():
|
||||
log_path = Path(__file__).resolve().parent.parent / log_file
|
||||
|
||||
_buffer = RingBuffer(maxlen=log_cfg.get("buffer_size", 2000))
|
||||
_writer = LogFileWriter(
|
||||
filepath=str(log_path),
|
||||
max_lines=log_cfg.get("max_file_lines", 10000),
|
||||
max_age_days=log_cfg.get("max_file_age_days", 7),
|
||||
)
|
||||
|
||||
# 从历史日志文件加载最近 N 条到缓冲区
|
||||
buffer_size = log_cfg.get("buffer_size", 2000)
|
||||
loaded = _load_from_logfile(str(log_path), buffer_size)
|
||||
if loaded > 0:
|
||||
logger.info("从日志文件恢复 %d 条历史记录", loaded)
|
||||
|
||||
logger.info("请求日志已启用 | 缓冲=%d | 文件=%s",
|
||||
buffer_size, log_path)
|
||||
|
||||
|
||||
def _load_from_logfile(filepath: str, max_entries: int) -> int:
|
||||
"""从 JSON Lines 日志文件读取最近 max_entries 条到缓冲区。"""
|
||||
try:
|
||||
p = Path(filepath)
|
||||
if not p.exists():
|
||||
return 0
|
||||
|
||||
# 从文件末尾反向读取(高效处理大文件)
|
||||
with open(p, "rb") as f:
|
||||
# 估算:每条约 200 bytes,读最后 max_entries * 250 bytes 足够
|
||||
chunk_size = max_entries * 250
|
||||
f.seek(0, 2) # 文件末尾
|
||||
file_size = f.tell()
|
||||
read_size = min(chunk_size, file_size)
|
||||
f.seek(max(0, file_size - read_size))
|
||||
raw = f.read().decode("utf-8", errors="replace")
|
||||
|
||||
# 跳过可能不完整的第一行
|
||||
lines = raw.split("\n")
|
||||
if file_size > read_size:
|
||||
# 第一行可能不完整,跳过
|
||||
lines = lines[1:]
|
||||
# 去掉末尾空行
|
||||
lines = [l for l in lines if l.strip()]
|
||||
|
||||
# 只取最后 max_entries 条
|
||||
lines = lines[-max_entries:]
|
||||
|
||||
count = 0
|
||||
for line in lines:
|
||||
try:
|
||||
data = json.loads(line)
|
||||
entry = RequestLogEntry(
|
||||
timestamp=data.get("timestamp", ""),
|
||||
method=data.get("method", ""),
|
||||
path=data.get("path", ""),
|
||||
status_code=data.get("status_code", 0),
|
||||
duration_ms=data.get("duration_ms", 0.0),
|
||||
client_ip=data.get("client_ip", ""),
|
||||
worker=data.get("worker", ""),
|
||||
response_code=data.get("response_code"),
|
||||
request_id=data.get("request_id"),
|
||||
)
|
||||
_buffer.append(entry)
|
||||
count += 1
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
continue
|
||||
|
||||
return count
|
||||
except Exception:
|
||||
logger.warning("从日志文件恢复历史记录失败", exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
def is_initialized() -> bool:
|
||||
return _buffer is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ASGI 中间件
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _should_log(path: str) -> bool:
|
||||
"""只记录 API 请求(/api/ 路径),跳过静态文件、健康检查等。"""
|
||||
return path.startswith("/api/")
|
||||
|
||||
|
||||
async def request_logging_middleware(request, call_next):
|
||||
"""记录每个请求的耗时、状态码等信息。"""
|
||||
|
||||
# 未初始化或不需要记录的路径 → 直接放行
|
||||
if _buffer is None or not _should_log(request.url.path):
|
||||
return await call_next(request)
|
||||
|
||||
start = time.perf_counter()
|
||||
|
||||
# 获取客户端 IP(优先级:X-Forwarded-For > X-Real-IP > client.host)
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
client_ip = forwarded.split(",")[0].strip()
|
||||
else:
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
client_ip = real_ip.strip()
|
||||
|
||||
response = await call_next(request)
|
||||
duration_ms = round((time.perf_counter() - start) * 1000, 2)
|
||||
|
||||
# 读取 worker URL(由 forward.py 在转发时写入 request.state)
|
||||
worker_url = getattr(request.state, "worker_url", None) or ""
|
||||
|
||||
# 提取响应 body 并解析业务字段(仅 JSON 响应)
|
||||
response_code = None
|
||||
request_id = None
|
||||
content_type = response.headers.get("content-type", "")
|
||||
|
||||
if "application/json" in content_type or "application/json" in (response.media_type or ""):
|
||||
# 读取 body(兼容 body_iterator 和 body 两种属性)
|
||||
body = getattr(response, "body", None)
|
||||
if body is None:
|
||||
body = b""
|
||||
async for chunk in response.body_iterator:
|
||||
body += chunk
|
||||
try:
|
||||
data = json.loads(body)
|
||||
response_code = data.get("code")
|
||||
request_id = data.get("request_id")
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
# 如果读取了 body_iterator,需要重建响应
|
||||
if not hasattr(response, "body") or response.body is None:
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
response = StarletteResponse(
|
||||
content=body,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.media_type,
|
||||
)
|
||||
|
||||
# 记录
|
||||
now = datetime.datetime.utcnow()
|
||||
entry = RequestLogEntry(
|
||||
timestamp=now.strftime("%Y-%m-%dT%H:%M:%S.") +
|
||||
f"{now.microsecond // 1000:03d}Z",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status_code=response.status_code,
|
||||
duration_ms=duration_ms,
|
||||
client_ip=client_ip,
|
||||
worker=worker_url,
|
||||
response_code=response_code,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
_buffer.append(entry)
|
||||
if _writer is not None:
|
||||
_writer.write(entry)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 统计查询
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_stats() -> Dict[str, Any]:
|
||||
"""基于缓冲区数据计算统计摘要,返回给统计页面使用。"""
|
||||
if _buffer is None:
|
||||
return {
|
||||
"summary": {"total": 0, "success_rate": 0, "avg_duration_ms": 0,
|
||||
"min_duration_ms": 0, "max_duration_ms": 0},
|
||||
"endpoints": [],
|
||||
"workers": [],
|
||||
"recent": [],
|
||||
"last_updated": datetime.datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
|
||||
snapshot = _buffer.snapshot()
|
||||
total = len(snapshot)
|
||||
|
||||
if total == 0:
|
||||
return {
|
||||
"summary": {"total": 0, "success_rate": 0, "avg_duration_ms": 0,
|
||||
"min_duration_ms": 0, "max_duration_ms": 0},
|
||||
"endpoints": [],
|
||||
"workers": [],
|
||||
"recent": [],
|
||||
"last_updated": datetime.datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
|
||||
# 汇总指标
|
||||
durations = [e.duration_ms for e in snapshot]
|
||||
ok_count = sum(1 for e in snapshot if e.response_code == 0)
|
||||
|
||||
# 按路径聚合
|
||||
by_path: Dict[str, dict] = {}
|
||||
for e in snapshot:
|
||||
path = e.path
|
||||
if path not in by_path:
|
||||
by_path[path] = {"count": 0, "total_duration": 0.0, "ok": 0}
|
||||
by_path[path]["count"] += 1
|
||||
by_path[path]["total_duration"] += e.duration_ms
|
||||
if e.response_code == 0:
|
||||
by_path[path]["ok"] += 1
|
||||
|
||||
endpoints = sorted(
|
||||
({
|
||||
"path": path,
|
||||
"count": v["count"],
|
||||
"avg_duration_ms": round(v["total_duration"] / v["count"], 1),
|
||||
"max_duration_ms": round(
|
||||
max(e.duration_ms for e in snapshot if e.path == path), 1),
|
||||
"success_rate": round(v["ok"] / v["count"] * 100, 1),
|
||||
} for path, v in by_path.items()),
|
||||
key=lambda x: -x["count"],
|
||||
)
|
||||
|
||||
# 最近 100 条(最新在前)
|
||||
recent_100 = snapshot[:100]
|
||||
recent = [
|
||||
{
|
||||
"timestamp": e.timestamp,
|
||||
"method": e.method,
|
||||
"path": e.path,
|
||||
"status_code": e.status_code,
|
||||
"duration_ms": e.duration_ms,
|
||||
"client_ip": e.client_ip,
|
||||
"worker": e.worker,
|
||||
"response_code": e.response_code,
|
||||
"request_id": e.request_id,
|
||||
}
|
||||
for e in recent_100
|
||||
]
|
||||
|
||||
# 按 worker 聚合
|
||||
by_worker: Dict[str, dict] = {}
|
||||
for e in snapshot:
|
||||
w = e.worker or "(网关本地)"
|
||||
if w not in by_worker:
|
||||
by_worker[w] = {"count": 0, "total_duration": 0.0, "ok": 0}
|
||||
by_worker[w]["count"] += 1
|
||||
by_worker[w]["total_duration"] += e.duration_ms
|
||||
if e.response_code == 0:
|
||||
by_worker[w]["ok"] += 1
|
||||
|
||||
workers = sorted(
|
||||
({
|
||||
"worker": w,
|
||||
"count": v["count"],
|
||||
"avg_duration_ms": round(v["total_duration"] / v["count"], 1),
|
||||
"success_rate": round(v["ok"] / v["count"] * 100, 1) if v["count"] else 0,
|
||||
} for w, v in by_worker.items()),
|
||||
key=lambda x: -x["count"],
|
||||
)
|
||||
|
||||
return {
|
||||
"summary": {
|
||||
"total": total,
|
||||
"success_rate": round(ok_count / total * 100, 1),
|
||||
"avg_duration_ms": round(sum(durations) / len(durations), 1),
|
||||
"min_duration_ms": round(min(durations), 1),
|
||||
"max_duration_ms": round(max(durations), 1),
|
||||
},
|
||||
"endpoints": endpoints,
|
||||
"workers": workers,
|
||||
"recent": recent,
|
||||
"last_updated": datetime.datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
@@ -130,6 +130,7 @@ async function submitTest() {
|
||||
const file = fileInput.files[0];
|
||||
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
|
||||
|
||||
const _reqStart = performance.now();
|
||||
|
||||
const btn = $('submitBtn');
|
||||
btn.disabled = true;
|
||||
@@ -149,21 +150,23 @@ async function submitTest() {
|
||||
try {
|
||||
const resp = await fetch(API_BASE + '/api/v1/face/measure', { method: 'POST', body: form });
|
||||
const json = await resp.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
|
||||
if (json.code === 0) {
|
||||
setStatus('✅ 请求成功 — request_id: ' + json.request_id, 'success');
|
||||
setStatus('✅ 请求成功 (' + _elapsed + 's) — request_id: ' + json.request_id, 'success');
|
||||
showOverlay(json.data.annotated_image_url);
|
||||
renderMetrics(json.data);
|
||||
$('metricsBar').classList.remove('hidden');
|
||||
} else {
|
||||
setStatus('❌ 业务错误 — code: ' + json.code + ' message: ' + json.message, 'error');
|
||||
setStatus('❌ 业务错误 (' + _elapsed + 's) — code: ' + json.code + ' message: ' + json.message, 'error');
|
||||
$('imgPanel').innerHTML = '<span class="placeholder">请求失败,无标注图</span>';
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('❌ 网络错误: ' + err.message, 'error');
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
setStatus('❌ 网络错误 (' + _elapsed + 's): ' + err.message, 'error');
|
||||
$('jsonContent').textContent = 'Error: ' + err.message;
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
$('imgPanel').innerHTML = '<span class="placeholder">请求失败</span>';
|
||||
|
||||
@@ -150,20 +150,24 @@ async function submitTest() {
|
||||
fd.append('hair_style', checked.join(','));
|
||||
fd.append('use_mask', $('useMask').value);
|
||||
fd.append('prompt', $('promptInput').value);
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/hair/grow', { method:'POST', body:fd });
|
||||
const json = await r.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
if (json.code === 0) {
|
||||
const results = json.data.results || [];
|
||||
setStatus('✅ ' + results.length + ' 个方案', 'success');
|
||||
setStatus('✅ ' + results.length + ' 个方案 (' + _elapsed + 's)', 'success');
|
||||
renderSchemes(results);
|
||||
} else {
|
||||
setStatus('❌ code=' + json.code + ' ' + json.message, 'error');
|
||||
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
|
||||
$('schemesContainer').innerHTML = '<div style="color:#9ca3af">无结果</div>';
|
||||
}
|
||||
} catch(e) { setStatus('❌ ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
|
||||
} catch(e) {
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
|
||||
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 提交'; }
|
||||
}
|
||||
|
||||
|
||||
@@ -159,9 +159,11 @@ async function submitTest() {
|
||||
fd.append('use_mask', $('useMask').value);
|
||||
fd.append('prompt', $('promptInput').value);
|
||||
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/hair/grow-b', { method:'POST', body:fd });
|
||||
const json = await r.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
|
||||
@@ -186,12 +188,14 @@ async function submitTest() {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('✅ 成功 | 发际线类型: ' + (d.hairline_type || '—'), 'success');
|
||||
setStatus('✅ 成功 (' + _elapsed + 's) | 发际线类型: ' + (d.hairline_type || '—'), 'success');
|
||||
} else {
|
||||
setStatus('❌ code=' + json.code + ' ' + json.message, 'error');
|
||||
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
|
||||
$('blendTop').style.display = 'none';
|
||||
}
|
||||
} catch(e) { setStatus('❌ ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
|
||||
} catch(e) {
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
|
||||
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 提交'; }
|
||||
}
|
||||
|
||||
|
||||
@@ -129,20 +129,24 @@ async function submitTest() {
|
||||
setStatus('⏳ 调用AI视觉模型...', 'info'); $('resultsArea').classList.add('hidden');
|
||||
|
||||
const fd = new FormData(); fd.append('image_file', f);
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/face/features', { method:'POST', body:fd });
|
||||
const json = await r.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
|
||||
if (json.code === 0) {
|
||||
const feats = JSON.parse(json.data.features);
|
||||
setStatus('✅ 分析完成 — ' + Object.keys(feats).length + ' 项特征', 'success');
|
||||
setStatus('✅ 分析完成 (' + _elapsed + 's) — ' + Object.keys(feats).length + ' 项特征', 'success');
|
||||
renderFeatures(feats);
|
||||
} else {
|
||||
setStatus('❌ code=' + json.code + ' ' + json.message, 'error');
|
||||
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
|
||||
}
|
||||
} catch(e) { setStatus('❌ ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
|
||||
} catch(e) {
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
|
||||
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 分析特征'; }
|
||||
}
|
||||
|
||||
|
||||
@@ -141,22 +141,26 @@ async function submitTest() {
|
||||
setStatus('请求中...', 'info'); $('resultsArea').classList.add('hidden');
|
||||
|
||||
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value);
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/hairline/generate', { method:'POST', body:fd });
|
||||
const json = await r.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
if (json.code === 0) {
|
||||
_images = json.data.hairline_images || [];
|
||||
_center = json.data.best_hairline_center_point;
|
||||
$('centerPoint').textContent = _center ? '(' + _center.x + ', ' + _center.y + ')' : '—';
|
||||
setStatus('✅ ' + _images.length + ' 个方案', 'success');
|
||||
setStatus('✅ ' + _images.length + ' 个方案 (' + _elapsed + 's)', 'success');
|
||||
renderGrid();
|
||||
if (_images.length) selectCard(0);
|
||||
} else {
|
||||
setStatus('❌ code=' + json.code + ' ' + json.message, 'error');
|
||||
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
|
||||
}
|
||||
} catch(e) { setStatus('❌ ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
|
||||
} catch(e) {
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
|
||||
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 提交'; }
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ async function submitTest() {
|
||||
const file = fileInput.files[0];
|
||||
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
|
||||
|
||||
const _reqStart = performance.now();
|
||||
|
||||
const btn = $('submitBtn');
|
||||
btn.disabled = true;
|
||||
@@ -151,21 +152,23 @@ async function submitTest() {
|
||||
try {
|
||||
const resp = await fetch(API_BASE + '/api/v1/face/measure-v2', { method: 'POST', body: form });
|
||||
const json = await resp.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
|
||||
if (json.code === 0) {
|
||||
setStatus('✅ 请求成功 — request_id: ' + json.request_id, 'success');
|
||||
setStatus('✅ 请求成功 (' + _elapsed + 's) — request_id: ' + json.request_id, 'success');
|
||||
showOverlay(json.data.annotated_image_url);
|
||||
renderMetrics(json.data);
|
||||
$('metricsBar').classList.remove('hidden');
|
||||
} else {
|
||||
setStatus('❌ 业务错误 — code: ' + json.code + ' message: ' + json.message, 'error');
|
||||
setStatus('❌ 业务错误 (' + _elapsed + 's) — code: ' + json.code + ' message: ' + json.message, 'error');
|
||||
$('imgPanel').innerHTML = '<span class="placeholder">请求失败,无标注图</span>';
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('❌ 网络错误: ' + err.message, 'error');
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
setStatus('❌ 网络错误 (' + _elapsed + 's): ' + err.message, 'error');
|
||||
$('jsonContent').textContent = 'Error: ' + err.message;
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
$('imgPanel').innerHTML = '<span class="placeholder">请求失败</span>';
|
||||
|
||||
@@ -151,20 +151,24 @@ async function submitTest() {
|
||||
fd.append('hair_style', checked.join(','));
|
||||
fd.append('use_mask', $('useMask').value);
|
||||
fd.append('prompt', $('promptInput').value);
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/hair/grow-v2', { method:'POST', body:fd });
|
||||
const json = await r.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
if (json.code === 0) {
|
||||
const results = json.data.results || [];
|
||||
setStatus('✅ ' + results.length + ' 个结果', 'success');
|
||||
setStatus('✅ ' + results.length + ' 个结果 (' + _elapsed + 's)', 'success');
|
||||
renderSchemes(results);
|
||||
} else {
|
||||
setStatus('❌ code=' + json.code + ' ' + json.message, 'error');
|
||||
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
|
||||
$('schemesContainer').innerHTML = '<div style="color:#9ca3af">无结果</div>';
|
||||
}
|
||||
} catch(e) { setStatus('❌ ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
|
||||
} catch(e) {
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
|
||||
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 提交'; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user