mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
* feat: add disable_metrics config option for WebUI * fix: remove dead code _disable_metrics, narrow exception catching * fix: add Russian translation for disable_metrics * fix: 将 disable_metrics 移到系统配置 * fix: 将 disable_metrics 元数据从 general 移到 system 分组 --------- Co-authored-by: Blueteemo <Blueteemo@users.noreply.github.com>
224 lines
7.4 KiB
Python
224 lines
7.4 KiB
Python
import asyncio
|
|
import os
|
|
import socket
|
|
import sys
|
|
import uuid
|
|
from contextlib import suppress
|
|
from typing import Any
|
|
|
|
import aiohttp
|
|
|
|
from astrbot.core import db_helper, logger
|
|
from astrbot.core.config import VERSION
|
|
|
|
|
|
class Metric:
|
|
_iid_cache = None
|
|
_has_uploaded_once = False
|
|
_upload_interval_seconds = 10 * 60
|
|
_max_pending_metric_groups = 64
|
|
_counter_fields = {"llm_tick", "msg_event_tick"}
|
|
_pending_metrics: dict[tuple[tuple[str, str], ...], dict[str, Any]] = {}
|
|
_flush_task: asyncio.Task | None = None
|
|
_lock: asyncio.Lock | None = None
|
|
_lock_loop: asyncio.AbstractEventLoop | None = None
|
|
|
|
@staticmethod
|
|
def _is_disabled() -> bool:
|
|
"""检查是否禁用指标上传(配置或环境变量)"""
|
|
if os.environ.get("ASTRBOT_DISABLE_METRICS", "0") == "1":
|
|
return True
|
|
try:
|
|
from astrbot.core import astrbot_config
|
|
|
|
return astrbot_config.get("disable_metrics", False)
|
|
except (ImportError, AttributeError, KeyError):
|
|
return False
|
|
|
|
@staticmethod
|
|
def get_installation_id():
|
|
"""获取或创建一个唯一的安装ID"""
|
|
if Metric._iid_cache is not None:
|
|
return Metric._iid_cache
|
|
|
|
config_dir = os.path.join(os.path.expanduser("~"), ".astrbot")
|
|
id_file = os.path.join(config_dir, ".installation_id")
|
|
|
|
if os.path.exists(id_file):
|
|
try:
|
|
with open(id_file) as f:
|
|
Metric._iid_cache = f.read().strip()
|
|
return Metric._iid_cache
|
|
except Exception:
|
|
pass
|
|
try:
|
|
os.makedirs(config_dir, exist_ok=True)
|
|
installation_id = str(uuid.uuid4())
|
|
with open(id_file, "w") as f:
|
|
f.write(installation_id)
|
|
Metric._iid_cache = installation_id
|
|
return installation_id
|
|
except Exception:
|
|
Metric._iid_cache = "null"
|
|
return "null"
|
|
|
|
@staticmethod
|
|
def _get_lock() -> asyncio.Lock:
|
|
loop = asyncio.get_running_loop()
|
|
if Metric._lock is None or Metric._lock_loop is not loop:
|
|
Metric._lock = asyncio.Lock()
|
|
Metric._lock_loop = loop
|
|
return Metric._lock
|
|
|
|
@staticmethod
|
|
def _format_group_value(value: Any) -> str:
|
|
return repr(value)
|
|
|
|
@staticmethod
|
|
def _get_metric_group_key(kwargs: dict[str, Any]) -> tuple[tuple[str, str], ...]:
|
|
return tuple(
|
|
sorted(
|
|
(key, Metric._format_group_value(value))
|
|
for key, value in kwargs.items()
|
|
if key not in Metric._counter_fields
|
|
)
|
|
)
|
|
|
|
@staticmethod
|
|
def _get_metric_group_fields(kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
key: value
|
|
for key, value in kwargs.items()
|
|
if key not in Metric._counter_fields
|
|
}
|
|
|
|
@staticmethod
|
|
def _coerce_counter(value: Any) -> int:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
@staticmethod
|
|
def _ensure_flush_task_locked() -> None:
|
|
if Metric._flush_task is None or Metric._flush_task.done():
|
|
Metric._flush_task = asyncio.create_task(Metric._flush_periodically())
|
|
|
|
@staticmethod
|
|
async def _save_platform_stats(kwargs: dict[str, Any]) -> None:
|
|
try:
|
|
if "adapter_name" in kwargs:
|
|
await db_helper.insert_platform_stats(
|
|
platform_id=kwargs["adapter_name"],
|
|
platform_type=kwargs.get("adapter_type", "unknown"),
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"保存指标到数据库失败: {e}")
|
|
|
|
@staticmethod
|
|
async def _add_pending_metrics(kwargs: dict[str, Any]) -> None:
|
|
key = Metric._get_metric_group_key(kwargs)
|
|
immediate_metrics = None
|
|
should_flush = False
|
|
lock = Metric._get_lock()
|
|
async with lock:
|
|
if not Metric._has_uploaded_once:
|
|
Metric._has_uploaded_once = True
|
|
immediate_metrics = dict(kwargs)
|
|
else:
|
|
pending = Metric._pending_metrics.setdefault(
|
|
key,
|
|
Metric._get_metric_group_fields(kwargs),
|
|
)
|
|
for counter_field in Metric._counter_fields:
|
|
if counter_field in kwargs:
|
|
pending[counter_field] = pending.get(
|
|
counter_field,
|
|
0,
|
|
) + Metric._coerce_counter(kwargs[counter_field])
|
|
Metric._ensure_flush_task_locked()
|
|
should_flush = (
|
|
len(Metric._pending_metrics) > Metric._max_pending_metric_groups
|
|
)
|
|
|
|
if immediate_metrics is not None:
|
|
await Metric._post_metrics(immediate_metrics)
|
|
return
|
|
if should_flush:
|
|
await Metric.flush()
|
|
|
|
@staticmethod
|
|
async def _flush_periodically() -> None:
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(Metric._upload_interval_seconds)
|
|
await Metric.flush()
|
|
|
|
lock = Metric._get_lock()
|
|
async with lock:
|
|
if not Metric._pending_metrics:
|
|
Metric._flush_task = None
|
|
return
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
current_task = asyncio.current_task()
|
|
with suppress(RuntimeError):
|
|
lock = Metric._get_lock()
|
|
async with lock:
|
|
if Metric._flush_task is current_task:
|
|
Metric._flush_task = None
|
|
|
|
@staticmethod
|
|
async def flush() -> None:
|
|
"""Flush pending metrics immediately."""
|
|
lock = Metric._get_lock()
|
|
async with lock:
|
|
pending_metrics = list(Metric._pending_metrics.values())
|
|
Metric._pending_metrics = {}
|
|
|
|
for metrics_data in pending_metrics:
|
|
await Metric._post_metrics(metrics_data)
|
|
await asyncio.sleep(0.25)
|
|
|
|
@staticmethod
|
|
async def _post_metrics(metrics_data: dict[str, Any]) -> None:
|
|
if Metric._is_disabled():
|
|
return
|
|
|
|
base_url = "https://tickstats.soulter.top/api/metric/90a6c2a1"
|
|
payload_metrics = dict(metrics_data)
|
|
payload_metrics["v"] = VERSION
|
|
payload_metrics["os"] = sys.platform
|
|
try:
|
|
payload_metrics["hn"] = socket.gethostname()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
payload_metrics["iid"] = Metric.get_installation_id()
|
|
except Exception:
|
|
pass
|
|
payload = {"metrics_data": payload_metrics}
|
|
|
|
try:
|
|
async with aiohttp.ClientSession(trust_env=True) as session:
|
|
async with session.post(base_url, json=payload, timeout=3) as response:
|
|
if response.status != 200:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
@staticmethod
|
|
async def upload(**kwargs) -> None:
|
|
"""上传相关非敏感的指标以更好地了解 AstrBot 的使用情况。上传的指标不会包含任何有关消息文本、用户信息等敏感信息。
|
|
|
|
Powered by TickStats.
|
|
"""
|
|
if Metric._is_disabled():
|
|
return
|
|
|
|
await Metric._save_platform_stats(kwargs)
|
|
await Metric._add_pending_metrics(dict(kwargs))
|