"""单应用进程的独立采集线程;探测有超时,写入异常不会进入业务请求。""" import logging import threading from collections import OrderedDict from copy import deepcopy from datetime import timedelta from pathlib import Path from time import monotonic, perf_counter import psutil from redis import Redis from sqlalchemy import create_engine, delete, select, text from sqlalchemy.orm import sessionmaker from app.core.config import BACKEND_DIR from app.core.monitoring import ( REQUEST_ID, Recorder, fingerprint, merge_metrics, minute_of, now_local, ) from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot from app.repositories import monitor_repository as repo from app.services.tplus import automation logger = logging.getLogger(__name__) class RuntimeErrorHandler(logging.Handler): """后台错误只采集固定摘要;HTTP 错误由中间件记录,避免同一异常重复。""" def __init__(self, recorder): super().__init__(logging.ERROR) self.recorder = recorder def emit(self, record): if ( record.name.startswith("app.") and ".monitor" not in record.name and REQUEST_ID.get() == "-" and getattr(record, "request_id", "-") == "-" ): self.recorder.runtime_error(record.name) class MonitorCollector: def __init__(self, settings, recorder: Recorder): self.settings, self.recorder = settings, recorder self.recorder.slow_ms = settings.monitor_slow_request_ms self.lock = threading.Lock() self.stopped = threading.Event() self.thread = None self.engine = None self.redis = None self.process = None self.samples = OrderedDict() self.conditions = {} self.unknown_codes = set() self.pending_incidents = {} self.since = {} self.persisted = set() self.last_log = 0 self.handler = RuntimeErrorHandler(recorder) self.state = { "collected_at": None, "last_persisted_at": None, "resources": {}, "dependencies": [], "tasks": {}, "alerts": [], "collection_status": "unknown", "collection_message": "正在等待首次采集。" if settings.monitor_enabled else "后台采集未启用。", } def current(self): with self.lock: return deepcopy(self.state) def sample_history(self): with self.lock: return deepcopy(self.samples) def start(self): if not self.settings.monitor_enabled or self.thread: return self.stopped.clear() logging.getLogger().addHandler(self.handler) self.thread = threading.Thread(target=self._run, name="system-monitor", daemon=True) self.thread.start() def stop(self): self.stopped.set() if self.thread: self.thread.join(timeout=15) if self.thread.is_alive(): logger.warning("监控采集线程未在关闭窗口内结束") return logging.getLogger().removeHandler(self.handler) if self.redis: self.redis.close() if self.engine: self.engine.dispose() self.thread = None def _factory(self): if self.engine is None: self.engine = create_engine( self.settings.database_url, pool_size=1, max_overflow=0, pool_timeout=2, pool_pre_ping=True, connect_args={"connect_timeout": 2, "read_timeout": 2, "write_timeout": 2}, ) return sessionmaker(self.engine, expire_on_commit=False) def _resources(self): first = self.process is None if first: self.process = psutil.Process() psutil.cpu_percent(None) self.process.cpu_percent(None) memory = psutil.virtual_memory() disk = psutil.disk_usage(str(BACKEND_DIR.anchor)) return { "cpu_percent": None if first else psutil.cpu_percent(None), "memory_percent": memory.percent, "disk_percent": disk.percent, "memory_total_mb": round(memory.total / 1024**2), "disk_free_gb": round(disk.free / 1024**3, 2), "process_cpu_percent": None if first else round(self.process.cpu_percent(None) / (psutil.cpu_count() or 1), 2), "process_memory_mb": round(self.process.memory_info().rss / 1024**2, 2), "scope": "进程可见系统资源,未换算容器限额" if Path("/.dockerenv").exists() else "应用所在系统的可见资源", "process_cpu_scope": "占可见 CPU 总量的比例", } def _dependencies(self, at): dependencies = [ { "name": "应用", "status": "normal", "message": "采集线程正在运行。", "checked_at": at, "duration_ms": None, } ] tasks = {"status": "unknown", "message": "尚无采集记录。", "alerts": []} for name in ("MySQL", "Redis"): started = perf_counter() try: if name == "MySQL": with self._factory()() as session: session.execute(text("SELECT 1")) else: if self.redis is None: self.redis = Redis.from_url( self.settings.redis_url, socket_connect_timeout=2, socket_timeout=2 ) self.redis.ping() status, message = "normal", "连接正常。" except Exception: status, message = "error", "连接检查失败,请核对服务和网络。" dependencies.append( { "name": name, "status": status, "message": message, "checked_at": at, "duration_ms": round((perf_counter() - started) * 1000, 2), } ) if dependencies[1]["status"] == "normal": try: with self._factory()() as session: tasks = automation.overview(session, settings=self.settings, now=at) source_configured = bool( self.settings.t_plus_enabled or self.settings.t_plus_connection_file ) if not source_configured: status, message = "unknown", "T+ 未配置。" elif tasks.get("stop_requested"): status, message = "unknown", "已主动请求停止采集。" elif not tasks["enabled"]: status, message = "unknown", "自动采集未启用,可在原页面查看手动任务。" else: status = "warning" if tasks["alerts"] else "normal" message = ( "存在采集异常,请查看任务与同步。" if tasks["alerts"] else "周期采集状态正常。" ) tasks.update(status=status, message=message) except Exception: tasks = { "status": "unknown", "message": "任务状态读取失败,请检查迁移和服务日志。", "alerts": [], } dependencies.append( { "name": "T+ 采集进程", "status": tasks["status"], "message": tasks["message"], "checked_at": at, "duration_ms": None, } ) return dependencies, tasks def _evaluate(self, at, resources, dependencies, tasks): active = {} self.unknown_codes = set() for dependency in dependencies: if dependency["status"] == "error": code = "MYSQL_UNAVAILABLE" if dependency["name"] == "MySQL" else "REDIS_UNAVAILABLE" active[code] = ("error", dependency["name"], dependency["message"], None) for field, threshold, code, title in ( ("cpu_percent", self.settings.monitor_cpu_threshold, "CPU_HIGH", "CPU 占用持续偏高"), ( "memory_percent", self.settings.monitor_memory_threshold, "MEMORY_HIGH", "内存占用持续偏高", ), ("disk_percent", self.settings.monitor_disk_threshold, "DISK_HIGH", "磁盘空间不足"), ): value = resources.get(field) if value is None: self.unknown_codes.add(code) self.since.pop(code, None) # 采集未知时不将仍存在的问题标为恢复。 if code in self.conditions: previous = self.conditions[code] active[code] = ( previous["level"], previous["component"], previous["message"], None, ) continue if value >= threshold: since = self.since.setdefault(code, at) # 重启后的持续时间尚未建立时,不关闭历史同类告警。 self.unknown_codes.add(code) if ( field == "disk_percent" or (at - since).total_seconds() >= self.settings.monitor_resource_duration_seconds ): active[code] = ("warning", "resources", title + ",请检查资源趋势。", None) else: self.since.pop(code, None) recent = self.recorder.metrics(minute_of(at) - timedelta(minutes=4)) metric = merge_metrics(m for endpoints in recent.values() for m in endpoints.values()) if ( metric["count"] >= self.settings.monitor_error_min_requests and metric["error_rate"] >= self.settings.monitor_error_rate_threshold ): active["HTTP_ERROR_RATE"] = ( "warning", "http", "近 5 分钟服务端错误率偏高,请查看失败接口。", None, ) if tasks.get("status") == "unknown" and "任务状态读取失败" in tasks.get("message", ""): active["TASK_STATUS_UNKNOWN"] = ("warning", "tplus", tasks["message"], None) for alert in tasks.get("alerts", []): active[alert["code"]] = ("warning", "tplus", alert["message"], alert.get("batch_no")) if "enabled" not in tasks: self.unknown_codes.update( {"WORKER_OFFLINE", "QUEUE_OVERDUE", "SYNC_STALE", "SYNC_REQUIRES_ATTENTION"} ) for code, previous in self.conditions.items(): if code in self.unknown_codes: active[code] = ( previous["level"], previous["component"], previous["message"], previous.get("batch_no"), ) for code, (level, component, message, batch) in active.items(): previous = self.conditions.get(code) incident = previous or { "id": 0, "instance_id": self.recorder.instance_id, "kind": "HEALTH", "first_seen": at, "occurrences": 0, "code": code, "fingerprint": fingerprint(code), "resolved_at": None, } incident.update( last_seen=at, occurrences=incident["occurrences"] + 1, level=level, component=component, message=message, batch_no=batch, ) self.conditions[code] = incident self.pending_incidents[code] = deepcopy(incident) for code in set(self.conditions) - set(active): incident = self.conditions.pop(code) incident.update(resolved_at=at) self.pending_incidents[code] = deepcopy(incident) def _persist(self, at, *, final=False): pending = self.recorder.take_events() try: with self._factory()() as session: # 首次采集复用历史未恢复事件,跨应用重启保留故障起点。 historical = {item.code: item for item in repo.active_events(session)} for code, item in self.pending_incidents.items(): row = historical.get(code) fields = {key: value for key, value in item.items() if key != "id"} if row is None: row = MonitorEvent(**fields) session.add(row) else: for key in ( "last_seen", "resolved_at", "level", "message", "batch_no", "instance_id", ): setattr(row, key, fields.get(key)) row.occurrences = ( max(row.occurrences, fields["occurrences"]) if item.get("id") == row.id else row.occurrences + fields["occurrences"] ) session.flush() if code in self.conditions: self.conditions[code].update( id=row.id, first_seen=row.first_seen, occurrences=row.occurrences ) for code, row in historical.items(): if ( code not in self.conditions and code not in self.pending_incidents and code not in self.unknown_codes ): # 只在本次完整采集后确认历史异常已恢复。 row.resolved_at = at for item in pending: session.add(MonitorEvent(instance_id=self.recorder.instance_id, **item)) completed = minute_of(at) + (timedelta(minutes=1) if final else timedelta()) metrics = self.recorder.metrics(end=completed) written = [] for minute in sorted(set(self.samples) | set(metrics)): if minute >= completed or minute in self.persisted: continue samples = self.samples.get(minute, []) endpoints = metrics.get(minute, {}) resources = dict(samples[-1]) if samples else {} for key in ( "cpu_percent", "memory_percent", "disk_percent", "process_cpu_percent", "process_memory_mb", ): numbers = [sample[key] for sample in samples if sample.get(key) is not None] resources[key] = round(sum(numbers) / len(numbers), 2) if numbers else None session.add( MonitorSnapshot( instance_id=self.recorder.instance_id, minute=minute, sample_count=len(samples), resources=resources, requests=merge_metrics(endpoints.values()), ) ) for (method, route), values in endpoints.items(): session.add( MonitorRequestMinute( instance_id=self.recorder.instance_id, minute=minute, method=method, route=route, metrics=values, ) ) written.append(minute) # 每次最多删除每表 1000 条,限定监控表及过期时间,避免长事务。 cutoff = at - timedelta(days=7) for model, column in ( (MonitorSnapshot, MonitorSnapshot.minute), (MonitorRequestMinute, MonitorRequestMinute.minute), (MonitorEvent, MonitorEvent.last_seen), ): expired = select(model.id).where(column < cutoff) if model is MonitorEvent: expired = expired.where( (MonitorEvent.kind != "HEALTH") | MonitorEvent.resolved_at.is_not(None) ) ids = session.scalars(expired.order_by(column).limit(1000)).all() if ids: session.execute(delete(model).where(model.id.in_(ids))) session.commit() self.persisted.update(written) self.persisted = { minute for minute in self.persisted if minute >= minute_of(at) - timedelta(minutes=10) } self.pending_incidents.clear() with self.lock: self.state.update( last_persisted_at=at, collection_status="normal", collection_message="采集正常,分钟历史保留 7 天。", ) except Exception: self.recorder.restore_events(pending) with self.lock: self.state.update( collection_status="warning", collection_message="历史写入失败,部分数据可能缺失;请检查数据库迁移和服务日志。", ) if monotonic() - self.last_log >= 60: logger.warning("监控历史写入失败,请检查迁移及数据库", exc_info=True) self.last_log = monotonic() def tick(self): at = now_local() try: resources = self._resources() except Exception: resources = {"scope": "资源采集失败"} logger.warning("系统资源采集失败", exc_info=True) dependencies, tasks = self._dependencies(at) with self.lock: self.samples.setdefault(minute_of(at), []).append(resources) while len(self.samples) > 10: self.samples.popitem(last=False) self._evaluate(at, resources, dependencies, tasks) with self.lock: self.state.update( collected_at=at, resources=resources, dependencies=dependencies, tasks=tasks ) self._persist(at) with self.lock: self.state["alerts"] = deepcopy(list(self.conditions.values())) if not resources.get("memory_percent"): self.state.update( collection_status="warning", collection_message="资源采集失败,当前资源指标未知。", ) def _run(self): while not self.stopped.is_set(): started = monotonic() try: self.tick() except Exception: logger.warning("监控采集周期失败", exc_info=True) with self.lock: self.state.update( collection_status="warning", collection_message="采集周期失败,请检查服务日志。", ) self.stopped.wait(max(0, 15 - (monotonic() - started))) if self.engine is not None: self._persist(now_local(), final=True)