hy-app-monitor/monitor/mongo_metrics.py
2026-04-22 14:39:40 +08:00

354 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from .cache import TTLCache
from .config import (
MONGO_CACHE_TTL_SECONDS,
MONGO_CONNECT_TIMEOUT_MS,
MONGO_DB_NAME,
MONGO_SERVER_SELECTION_TIMEOUT_MS,
MONGO_SOCKET_TIMEOUT_MS,
MONGO_URI,
utc_now,
)
# Mongo 结果缓存。
MONGO_CACHE = TTLCache[dict[str, Any]](MONGO_CACHE_TTL_SECONDS)
def mongo_error_payload(error: str) -> dict[str, Any]:
# 统一返回 Mongo 错误结构。
return {
"ok": False,
"updatedAt": utc_now(),
"summary": {
"databaseTotal": 0,
"collectionTotal": 0,
"objectTotal": 0,
"storageSizeMb": 0.0,
"dataSizeMb": 0.0,
"connectionCurrent": 0,
"connectionAvailable": 0,
},
"info": {},
"connections": {},
"memory": {},
"network": {},
"opcounters": {},
"wiredTigerCache": {},
"replicaSet": {"configured": False, "members": []},
"databases": [],
"error": error,
}
def iso_datetime(value: Any) -> str:
# datetime 对象转成 UTC ISO 字符串。
if isinstance(value, datetime):
return value.astimezone(timezone.utc).replace(microsecond=0).isoformat()
# 其余情况直接转字符串。
return str(value or "")
def build_mongo_client() -> tuple[Any | None, str]:
# 没配置 URI 时直接报错。
if not MONGO_URI:
return None, "missing MONGO_URI in .env"
try:
# 运行时导入 PyMongo避免缺依赖时整个服务起不来。
from pymongo import MongoClient
except Exception as exc: # noqa: BLE001
# 依赖不存在时返回错误文本。
return None, f"pymongo unavailable: {exc}"
# 创建 MongoClient。
client = MongoClient(
MONGO_URI,
appname="hy-app-monitor",
connectTimeoutMS=MONGO_CONNECT_TIMEOUT_MS,
serverSelectionTimeoutMS=MONGO_SERVER_SELECTION_TIMEOUT_MS,
socketTimeoutMS=MONGO_SOCKET_TIMEOUT_MS,
)
# 返回连接对象。
return client, ""
def safe_get(mapping: dict[str, Any], key: str, default: float = 0.0) -> float:
# 读取数值型字段。
value = mapping.get(key, default)
try:
# 尽量转成 float 返回。
return float(value)
except Exception: # noqa: BLE001
# 转换失败时回退默认值。
return default
def build_replica_payload(client: Any) -> dict[str, Any]:
try:
# 读取复制集状态。
payload = client.admin.command("replSetGetStatus")
except Exception as exc: # noqa: BLE001
# 未配置复制集或权限不足时给出降级信息。
return {
"configured": False,
"error": str(exc),
"name": "",
"members": [],
"summary": {"memberTotal": 0, "healthyMemberTotal": 0, "primary": ""},
}
# 原始成员列表。
members = payload.get("members") or []
# 尝试定位 primary 成员。
primary_member = next((item for item in members if str(item.get("stateStr") or "") == "PRIMARY"), None)
# 拿到 primary optime 时间。
primary_optime = primary_member.get("optimeDate") if primary_member else None
# 归一化后的成员列表。
normalized_members: list[dict[str, Any]] = []
# 健康成员数量。
healthy_member_total = 0
# 逐个成员整理结构。
for item in members:
# 当前成员 optime 时间。
optime_date = item.get("optimeDate")
# 默认 lag 为空。
lag_seconds: int | None = None
# 主节点和当前成员都有 optime 时间时计算 lag。
if isinstance(primary_optime, datetime) and isinstance(optime_date, datetime):
lag_seconds = max(int((primary_optime - optime_date).total_seconds()), 0)
# 成员 health 为 1 视为健康。
healthy = float(item.get("health") or 0) >= 1.0
# 统计健康成员数。
if healthy:
healthy_member_total += 1
# 追加成员结构。
normalized_members.append(
{
"name": str(item.get("name") or "").strip(),
"state": int(item.get("state") or 0),
"stateStr": str(item.get("stateStr") or "").strip(),
"health": float(item.get("health") or 0.0),
"uptime": int(item.get("uptime") or 0),
"optimeDate": iso_datetime(optime_date),
"lagSeconds": lag_seconds,
"self": bool(item.get("self")),
}
)
# 返回复制集结构。
return {
"configured": True,
"error": "",
"name": str(payload.get("set") or "").strip(),
"members": normalized_members,
"summary": {
"memberTotal": len(normalized_members),
"healthyMemberTotal": healthy_member_total,
"primary": str(primary_member.get("name") or "").strip() if primary_member else "",
},
}
def build_database_payloads(client: Any, databases: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
# 所有数据库的详情列表。
payloads: list[dict[str, Any]] = []
# 汇总统计。
summary = {
"databaseTotal": 0,
"collectionTotal": 0,
"objectTotal": 0,
"storageSizeMb": 0.0,
"dataSizeMb": 0.0,
}
# 逐个数据库补充 dbStats。
for item in databases:
# 读取数据库名。
name = str(item.get("name") or "").strip()
# 没名字时跳过。
if not name:
continue
try:
# 获取 dbStats并把 scale 设成 1方便后端自己换算。
stats = client[name].command("dbStats", 1, scale=1)
except Exception as exc: # noqa: BLE001
# 获取失败时仍保留基础信息。
payloads.append(
{
"name": name,
"empty": bool(item.get("empty")),
"sizeOnDiskMb": round(safe_get(item, "sizeOnDisk") / 1024 / 1024, 2),
"error": str(exc),
}
)
continue
# 读取 collection 总数。
collections = int(stats.get("collections") or 0)
# 读取 object 总数。
objects = int(stats.get("objects") or 0)
# 读取 dataSize。
data_size_mb = round(safe_get(stats, "dataSize") / 1024 / 1024, 2)
# 读取 storageSize。
storage_size_mb = round(safe_get(stats, "storageSize") / 1024 / 1024, 2)
# 读取 index 数。
indexes = int(stats.get("indexes") or 0)
# 读取 indexSize。
index_size_mb = round(safe_get(stats, "indexSize") / 1024 / 1024, 2)
# 读取 avgObjSize。
avg_obj_size_b = round(safe_get(stats, "avgObjSize"), 2)
# 汇总数据库级统计。
summary["databaseTotal"] += 1
summary["collectionTotal"] += collections
summary["objectTotal"] += objects
summary["storageSizeMb"] += storage_size_mb
summary["dataSizeMb"] += data_size_mb
# 追加当前数据库详情。
payloads.append(
{
"name": name,
"empty": bool(item.get("empty")),
"collections": collections,
"views": int(stats.get("views") or 0),
"objects": objects,
"dataSizeMb": data_size_mb,
"storageSizeMb": storage_size_mb,
"indexCount": indexes,
"indexSizeMb": index_size_mb,
"avgObjSizeBytes": avg_obj_size_b,
"sizeOnDiskMb": round(safe_get(item, "sizeOnDisk") / 1024 / 1024, 2),
}
)
# 数据库列表按 storageSize 倒序显示。
payloads.sort(key=lambda item: float(item.get("storageSizeMb") or 0.0), reverse=True)
# 汇总值保留两位小数。
summary["storageSizeMb"] = round(summary["storageSizeMb"], 2)
summary["dataSizeMb"] = round(summary["dataSizeMb"], 2)
# 返回数据库列表和汇总信息。
return payloads, summary
def build_mongo_payload() -> dict[str, Any]:
# 先构造客户端。
client, error = build_mongo_client()
# 客户端不可用时直接返回错误结构。
if client is None:
return mongo_error_payload(error)
try:
# 先做一次 ping保证连接可用。
client.admin.command("ping")
# 读取 serverStatus全局指标都在这里。
server_status = client.admin.command("serverStatus", recordStats=0)
# 读取数据库列表。
database_list = client.admin.command("listDatabases", 1)
# 读取复制集状态。
replica_payload = build_replica_payload(client)
# 拿到数据库项数组。
database_items = list(database_list.get("databases") or [])
# 构建数据库详情和汇总。
database_payloads, database_summary = build_database_payloads(client, database_items)
# 读取连接数信息。
connections = server_status.get("connections") or {}
# 读取内存信息。
memory = server_status.get("mem") or {}
# 读取网络信息。
network = server_status.get("network") or {}
# 读取操作计数器。
opcounters = server_status.get("opcounters") or {}
# 读取 WiredTiger 指标。
wired_tiger = server_status.get("wiredTiger") or {}
# 读取 cache 指标。
wired_tiger_cache = wired_tiger.get("cache") or {}
# 组装最终返回结构。
return {
"ok": True,
"updatedAt": utc_now(),
"summary": {
**database_summary,
"connectionCurrent": int(connections.get("current") or 0),
"connectionAvailable": int(connections.get("available") or 0),
},
"info": {
"host": str(server_status.get("host") or "").strip(),
"version": str(server_status.get("version") or "").strip(),
"process": str(server_status.get("process") or "").strip(),
"uptimeSeconds": int(server_status.get("uptime") or 0),
"localTime": iso_datetime(server_status.get("localTime")),
"focusedDatabase": MONGO_DB_NAME,
},
"connections": {
"current": int(connections.get("current") or 0),
"available": int(connections.get("available") or 0),
"totalCreated": int(connections.get("totalCreated") or 0),
"active": int(connections.get("active") or 0),
"threaded": int(connections.get("threaded") or 0),
},
"memory": {
"residentMb": safe_get(memory, "resident"),
"virtualMb": safe_get(memory, "virtual"),
"mappedMb": safe_get(memory, "mapped"),
"mappedWithJournalMb": safe_get(memory, "mappedWithJournal"),
"bits": int(memory.get("bits") or 0),
},
"network": {
"bytesIn": int(network.get("bytesIn") or 0),
"bytesOut": int(network.get("bytesOut") or 0),
"numRequests": int(network.get("numRequests") or 0),
},
"opcounters": {
"insert": int(opcounters.get("insert") or 0),
"query": int(opcounters.get("query") or 0),
"update": int(opcounters.get("update") or 0),
"delete": int(opcounters.get("delete") or 0),
"getmore": int(opcounters.get("getmore") or 0),
"command": int(opcounters.get("command") or 0),
},
"wiredTigerCache": {
"bytesCurrentlyInCache": int(wired_tiger_cache.get("bytes currently in the cache") or 0),
"trackedDirtyBytesInCache": int(
wired_tiger_cache.get("tracked dirty bytes in the cache") or 0
),
"maximumBytesConfigured": int(wired_tiger_cache.get("maximum bytes configured") or 0),
"pagesReadIntoCache": int(wired_tiger_cache.get("pages read into cache") or 0),
"pagesWrittenFromCache": int(wired_tiger_cache.get("pages written from cache") or 0),
},
"replicaSet": replica_payload,
"databases": database_payloads,
}
except Exception as exc: # noqa: BLE001
# 任意异常都降级成统一错误结构。
return mongo_error_payload(str(exc))
finally:
# 连接用完就关闭。
client.close()
def get_mongo_payload() -> dict[str, Any]:
# 使用缓存保护 Mongo 采集。
return MONGO_CACHE.get_or_build(build_mongo_payload)