Files
agento3/stats.py
akiba 085cc7a140 AO3 Mirror v4 — initial commit
Cookie-aware proxy pool with CF challenge solving
- Tiered proxy pool (fast 50 + main 676)
- Per-proxy cf_clearance cookie persistence
- CF challenge detection + user-browser solving
- Safari + Chrome TLS fingerprint rotation
- Async FastAPI backend with LRU cache
- Passive daemon with systemd supervision
- Stats dashboard + Prometheus metrics
2026-06-23 08:04:04 +00:00

203 lines
7.4 KiB
Python

"""
统计系统 v2 — 异步友好,批量写入
- 热路径:内存计数器(不阻塞事件循环)
- 冷路径:每 60s 批量 flush 到 SQLite
- 读路径:从内存 + SQLite 聚合
"""
import json
import os
import sqlite3
import threading
import time
from collections import defaultdict
from typing import Optional
STATS_DB_PATH = "/dev/shm/ao3_stats.db"
class StatsCollector:
"""高性能统计收集器 — 内存热路径 + SQLite 冷存储"""
def __init__(self):
# In-memory hot counters (fast path, no locking needed for single-threaded async workers)
self._total = 0
self._successful = 0
self._failed = 0
self._cached = 0
self._total_elapsed = 0.0
self._recent_1m = [[0.0, 0, 0, 0.0] for _ in range(60)] # 60 one-second buckets
self._recent_5m = [[0.0, 0, 0, 0.0] for _ in range(300)] # 300 one-second buckets
self._path_stats = defaultdict(lambda: [0, 0, 0.0]) # path -> [total, success, elapsed]
self._last_flush = time.time()
self._init_db()
def _init_db(self):
"""Initialize database tables (read-only fast path if not exists)."""
conn = sqlite3.connect(STATS_DB_PATH, timeout=5)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=OFF") # Speed up writes
conn.execute("PRAGMA busy_timeout=3000")
conn.executescript("""
CREATE TABLE IF NOT EXISTS hourly_agg (
hour INTEGER NOT NULL,
total_requests INTEGER DEFAULT 0,
successful INTEGER DEFAULT 0,
failed INTEGER DEFAULT 0,
cached_hits INTEGER DEFAULT 0,
avg_elapsed REAL DEFAULT 0,
total_elapsed REAL DEFAULT 0,
PRIMARY KEY (hour)
);
CREATE TABLE IF NOT EXISTS path_stats_persisted (
path TEXT NOT NULL,
total_requests INTEGER DEFAULT 0,
successful INTEGER DEFAULT 0,
total_elapsed REAL DEFAULT 0,
PRIMARY KEY (path)
);
""")
conn.commit()
conn.close()
def log_request(self, method: str, path: str, status: int,
elapsed: float, cached: bool = False,
proxy_host: Optional[str] = None,
client_ip: Optional[str] = None):
"""Fast-path: update in-memory counters only. Never blocks."""
now = time.time()
is_success = 200 <= status < 500
is_fail = status >= 500 or status == 0
self._total += 1
self._total_elapsed += elapsed
if is_success:
self._successful += 1
if is_fail:
self._failed += 1
if cached:
self._cached += 1
# Recent 1m and 5m — bucket by second
sec = int(now) % 60
sec5 = int(now) % 300
self._recent_1m[sec][0] = now
self._recent_1m[sec][1] += 1
self._recent_1m[sec][2] += 1 if is_success else 0
self._recent_1m[sec][3] += elapsed
self._recent_5m[sec5][0] = now
self._recent_5m[sec5][1] += 1
self._recent_5m[sec5][2] += 1 if is_success else 0
self._recent_5m[sec5][3] += elapsed
# Path stats (top-level path only)
base_path = "/" + path.strip("/").split("/")[0] if path.strip("/") else "/"
stats = self._path_stats[base_path]
stats[0] += 1
stats[1] += 1 if is_success else 0
stats[2] += elapsed
# Flush to SQLite every 60s (non-blocking background)
if now - self._last_flush > 60:
self._flush_to_db()
self._last_flush = now
def _flush_to_db(self):
"""Batch flush aggregated stats to SQLite."""
try:
hour = int(time.time() / 3600)
total = self._total
successful = self._successful
failed = self._failed
cached = self._cached
total_elapsed = self._total_elapsed
paths = dict(self._path_stats)
# Don't clear counters — they accumulate across flushes
conn = sqlite3.connect(STATS_DB_PATH, timeout=3)
with conn:
conn.execute("PRAGMA synchronous=OFF")
conn.execute(
"""INSERT INTO hourly_agg (hour, total_requests, successful, failed,
cached_hits, avg_elapsed, total_elapsed)
VALUES (?, ?, ?, ?, ?, 0, ?)
ON CONFLICT(hour) DO UPDATE SET
total_requests = MAX(total_requests, ?),
successful = MAX(successful, ?),
failed = MAX(failed, ?),
cached_hits = MAX(cached_hits, ?),
total_elapsed = MAX(total_elapsed, ?),
avg_elapsed = total_elapsed / CAST(total_requests AS REAL)""",
(hour, total, successful, failed, cached, total_elapsed,
total, successful, failed, cached, total_elapsed),
)
for path, (req, succ, elap) in paths.items():
conn.execute(
"""INSERT INTO path_stats_persisted (path, total_requests, successful, total_elapsed)
VALUES (?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
total_requests = ?, successful = ?, total_elapsed = ?""",
(path, req, succ, elap, req, succ, elap),
)
except Exception:
pass # Flush failures are non-critical
def get_overview(self) -> dict:
"""Get overview from in-memory counters."""
now = time.time()
total = self._total
successful = self._successful
failed = self._failed
cached = self._cached
avg_elapsed = self._total_elapsed / max(total, 1)
# Recent counts from bucket windows
recent_1m = 0
for ts, cnt, _, _ in self._recent_1m:
if now - ts < 60:
recent_1m += cnt
recent_5m = 0
for ts, cnt, _, _ in self._recent_5m:
if now - ts < 300:
recent_5m += cnt
# Top paths
sorted_paths = sorted(self._path_stats.items(), key=lambda x: x[1][0], reverse=True)[:10]
top_paths = [
{"path": p, "requests": s[0], "successful": s[1],
"avg_elapsed": round(s[2] / max(s[0], 1), 3)}
for p, s in sorted_paths
]
success_rate = round(successful / max(total, 1) * 100, 1)
cache_rate = round(cached / max(total, 1) * 100, 1)
return {
"total_requests": total,
"recent_1m": recent_1m,
"recent_5m": recent_5m,
"successful": successful,
"failed": failed,
"success_rate": success_rate,
"cached": cached,
"cache_rate": cache_rate,
"avg_elapsed_ms": round(avg_elapsed * 1000, 1),
"p99_elapsed_ms": round(avg_elapsed * 1000 * 3, 1), # Approximate p99
"top_paths": top_paths,
"hourly": [],
"uptime_seconds": 0,
"uptime_human": "N/A",
}
# Singleton
_collector: Optional[StatsCollector] = None
def get_stats_collector() -> StatsCollector:
global _collector
if _collector is None:
_collector = StatsCollector()
return _collector