Files
getDomain/domain-api/app/services/debug_event_service.py

1044 lines
42 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
import json
import socket
import threading
import urllib.error
import urllib.request
from datetime import datetime, timedelta
from app.core.config import settings
from app.core.db import db_read_retry, get_db
_DEBUG_SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS detect_debug_events (
id BIGSERIAL PRIMARY KEY,
source_region VARCHAR(32) NOT NULL DEFAULT '',
node_code VARCHAR(64) NOT NULL DEFAULT '',
service VARCHAR(64) NOT NULL DEFAULT '',
event_type VARCHAR(64) NOT NULL DEFAULT '',
level VARCHAR(16) NOT NULL DEFAULT 'info',
message TEXT NOT NULL DEFAULT '',
payload_json JSONB,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_detect_debug_events_created
ON detect_debug_events(created_at DESC);
"""
_DEBUG_SCHEMA_READY = False
_DEBUG_SCHEMA_LOCK = threading.Lock()
def _format_time(value: datetime | None) -> str:
return value.isoformat(sep=" ", timespec="seconds") if value else ""
def _parse_time(value: str | None) -> datetime | None:
text = str(value or "").strip()
if not text:
return None
normalized = text.replace("T", " ")
for pattern in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
try:
return datetime.strptime(normalized, pattern)
except ValueError:
continue
return None
def _normalize_region(value: str | None, fallback: str) -> str:
text = str(value or "").strip()
if not text or text == "unknown":
return str(fallback or "unknown").strip() or "unknown"
return text
def _safe_json_dumps(payload: object) -> str:
try:
return json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)
except Exception:
return json.dumps({"_serialization_error": "payload_not_json_serializable"}, ensure_ascii=False, sort_keys=True)
def _normalize_message(value: str | None, fallback: str = "") -> str:
text = str(value or "").strip()
if not text:
text = fallback
return text[:2000]
def _response_code_ok(value: object) -> bool:
return value in (0, "0", None, "")
def _debug_ingest_url(base_url: str) -> str:
text = str(base_url or "").strip().rstrip("/")
if not text:
return ""
if text.endswith("/api/v1"):
return f"{text}/runtime/debug-ingest"
if text.endswith("/api/v1/runtime"):
return f"{text}/debug-ingest"
return f"{text}/api/v1/runtime/debug-ingest"
def ensure_debug_event_schema() -> None:
global _DEBUG_SCHEMA_READY
if _DEBUG_SCHEMA_READY:
return
with _DEBUG_SCHEMA_LOCK:
if _DEBUG_SCHEMA_READY:
return
with get_db() as conn:
conn.autocommit = False
with conn.cursor() as cur:
cur.execute(_DEBUG_SCHEMA_SQL)
conn.commit()
_DEBUG_SCHEMA_READY = True
def append_debug_event(
*,
service: str,
event_type: str,
message: str,
level: str = "info",
payload: dict | None = None,
source_region: str | None = None,
node_code: str | None = None,
) -> int:
ensure_debug_event_schema()
normalized_region = _normalize_region(source_region, settings.node_region)
normalized_node_code = str(node_code or settings.node_code).strip() or settings.node_code
normalized_service = str(service or "").strip() or "unknown"
normalized_event_type = str(event_type or "").strip() or "event"
normalized_level = str(level or "info").strip() or "info"
normalized_message = _normalize_message(message, fallback=f"{normalized_service}/{normalized_event_type}")
payload_text = _safe_json_dumps(payload or {})
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id
FROM detect_debug_events
WHERE source_region = %s
AND node_code = %s
AND service = %s
AND event_type = %s
AND level = %s
AND message = %s
AND COALESCE(payload_json, '{}'::jsonb) = %s::jsonb
AND created_at >= CURRENT_TIMESTAMP - interval '60 seconds'
ORDER BY created_at DESC, id DESC
LIMIT 1
""",
(
normalized_region,
normalized_node_code,
normalized_service,
normalized_event_type,
normalized_level,
normalized_message,
payload_text,
),
)
existing = cur.fetchone()
if existing:
return int(existing[0])
cur.execute(
"""
INSERT INTO detect_debug_events (
source_region, node_code, service, event_type, level, message, payload_json, created_at
) VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, CURRENT_TIMESTAMP)
RETURNING id
""",
(
normalized_region,
normalized_node_code,
normalized_service,
normalized_event_type,
normalized_level,
normalized_message,
payload_text,
),
)
record_id = int(cur.fetchone()[0])
conn.commit()
return record_id
def _load_debug_event_record(record_id: int) -> dict | None:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, source_region, node_code, service, event_type, level, message, payload_json, created_at
FROM detect_debug_events
WHERE id = %s
LIMIT 1
""",
(int(record_id),),
)
row = cur.fetchone()
if not row:
return None
return {
"id": int(row[0]),
"source_region": str(row[1] or ""),
"node_code": str(row[2] or ""),
"service": str(row[3] or ""),
"event_type": str(row[4] or ""),
"level": str(row[5] or "info"),
"message": str(row[6] or ""),
"payload": row[7] if isinstance(row[7], dict) else {},
"created_at": _format_time(row[8]),
}
def _normalize_worker_log_event(debug_event: dict) -> dict | None:
payload = dict(debug_event.get("payload") or {})
message = _normalize_message(debug_event.get("message"), fallback="worker_log")
domain = str(payload.get("domain") or "").strip().lower()
status = str(payload.get("status") or "").strip().lower()
if not domain and ":" in message:
domain = message.rsplit(":", 1)[-1].strip().lower()
event_type = ""
if status == "completed":
event_type = "domain_completed"
elif status == "failed":
event_type = "domain_failed"
elif status == "blacklisted":
event_type = "domain_blacklisted"
elif "开始检测域名" in message:
event_type = "domain_started"
elif "域名检测完成" in message:
event_type = "domain_completed"
payload.setdefault("status", "completed")
elif "域名已命中黑名单" in message or "命中黑名单" in message:
event_type = "domain_blacklisted"
payload.setdefault("status", "blacklisted")
elif "域名检测失败" in message:
event_type = "domain_failed"
payload.setdefault("status", "failed")
if not event_type or not domain:
return None
payload.setdefault("domain", domain)
payload["imported_from_debug_event"] = True
payload["debug_event_record_id"] = int(debug_event.get("id") or 0)
payload["debug_event_source_region"] = str(debug_event.get("source_region") or "")
return {
"node_code": str(debug_event.get("node_code") or "").strip(),
"event_type": event_type,
"level": str(debug_event.get("level") or "info"),
"message": message,
"payload": payload,
"created_at": str(debug_event.get("created_at") or "").strip(),
}
def _normalize_debug_event_job_identity(payload: dict | None) -> dict:
normalized_payload = dict(payload or {}) if isinstance(payload, dict) else {}
nested_job = normalized_payload.get("job") if isinstance(normalized_payload.get("job"), dict) else {}
raw_job_id = normalized_payload.get("job_id")
if raw_job_id in (None, "", 0, "0"):
raw_job_id = normalized_payload.get("target_job_id")
if raw_job_id in (None, "", 0, "0"):
raw_job_id = nested_job.get("job_id")
try:
job_id = int(raw_job_id or 0)
except Exception:
job_id = 0
job_code = str(
normalized_payload.get("job_code")
or normalized_payload.get("target_job_code")
or nested_job.get("job_code")
or ""
).strip()
cycle_token = str(normalized_payload.get("cycle_token") or nested_job.get("cycle_token") or "").strip()
return {
"job_id": job_id,
"job_code": job_code,
"cycle_token": cycle_token,
"has_identity": bool(job_id > 0 or job_code),
}
def _load_detect_job_summary_by_job_code(job_code: str, *, event_limit: int = 1) -> dict | None:
normalized_job_code = str(job_code or "").strip()
if not normalized_job_code:
return None
from app.services.detect_job_service import get_detect_job_summary
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id
FROM detect_jobs
WHERE job_code = %s
ORDER BY id DESC
LIMIT 1
""",
(normalized_job_code,),
)
row = cur.fetchone()
if not row:
return None
return get_detect_job_summary(int(row[0]), event_limit=event_limit)
def _resolve_target_job_for_debug_event(event_payload: dict | None) -> tuple[dict | None, str]:
from app.services.detect_job_service import get_active_detect_job_summary, get_detect_job_summary
identity = _normalize_debug_event_job_identity(event_payload)
if not identity["has_identity"]:
return None, "missing_job_identity"
payload_job_id = int(identity["job_id"] or 0)
payload_job_code = str(identity["job_code"] or "").strip()
payload_cycle_token = str(identity["cycle_token"] or "").strip()
active_job = get_active_detect_job_summary(event_limit=1) or {}
active_job_id = int(active_job.get("job_id") or 0)
active_job_code = str(active_job.get("job_code") or active_job.get("runtime_job_code") or "").strip()
target_job: dict | None = None
if payload_job_id > 0:
if active_job_id == payload_job_id:
target_job = active_job
else:
target_job = get_detect_job_summary(payload_job_id, event_limit=1)
elif payload_job_code:
if active_job_code and active_job_code == payload_job_code:
target_job = active_job
else:
target_job = _load_detect_job_summary_by_job_code(payload_job_code, event_limit=1)
if not target_job:
return None, "job_not_found"
target_job_id = int(target_job.get("job_id") or 0)
target_job_code = str(target_job.get("job_code") or target_job.get("runtime_job_code") or "").strip()
target_cycle_token = str(target_job.get("current_cycle_token") or "").strip()
if payload_job_id > 0 and target_job_id > 0 and target_job_id != payload_job_id:
return None, "job_mismatch"
if payload_job_code and target_job_code and target_job_code != payload_job_code:
return None, "job_mismatch"
if payload_cycle_token and target_cycle_token and payload_cycle_token != target_cycle_token:
return None, "cycle_mismatch"
return target_job, "matched"
def _ingest_worker_log_into_active_job(debug_event: dict) -> dict:
if str(debug_event.get("event_type") or "").strip() != "worker_log":
return {"imported": False, "reason": "not_worker_log"}
normalized_event = _normalize_worker_log_event(debug_event)
if not normalized_event:
return {"imported": False, "reason": "not_domain_progress_event"}
from app.services.sync_push_service import (
_apply_detect_result_event_to_domain,
_apply_detect_result_event_to_job_item,
)
target_job, resolve_reason = _resolve_target_job_for_debug_event(normalized_event.get("payload"))
if not target_job:
return {"imported": False, "reason": resolve_reason}
target_job_id = int(target_job.get("job_id") or 0)
if target_job_id <= 0:
return {"imported": False, "reason": "no_active_job"}
debug_event_record_id = int(debug_event.get("id") or 0)
updated_job_items = 0
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id
FROM detect_run_events
WHERE job_id = %s
AND (payload_json->>'debug_event_record_id') = %s
ORDER BY id DESC
LIMIT 1
""",
(target_job_id, str(debug_event_record_id)),
)
existing = cur.fetchone()
if existing:
return {
"imported": False,
"reason": "deduplicated",
"target_job_id": target_job_id,
"detect_run_event_id": int(existing[0]),
}
created_at = _parse_time(normalized_event.get("created_at"))
payload_json = _safe_json_dumps(normalized_event.get("payload") or {})
if created_at:
cur.execute(
"""
INSERT INTO detect_run_events (
job_id, node_code, event_type, level, message, payload_json, created_at
) VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s)
RETURNING id
""",
(
target_job_id,
normalized_event["node_code"],
normalized_event["event_type"],
normalized_event["level"],
normalized_event["message"],
payload_json,
created_at,
),
)
else:
cur.execute(
"""
INSERT INTO detect_run_events (
job_id, node_code, event_type, level, message, payload_json
) VALUES (%s, %s, %s, %s, %s, %s::jsonb)
RETURNING id
""",
(
target_job_id,
normalized_event["node_code"],
normalized_event["event_type"],
normalized_event["level"],
normalized_event["message"],
payload_json,
),
)
detect_run_event_id = int((cur.fetchone() or [0])[0] or 0)
_apply_detect_result_event_to_domain(cur, normalized_event)
updated_job_items = _apply_detect_result_event_to_job_item(
cur,
target_job_id=target_job_id,
event=normalized_event,
)
conn.commit()
return {
"imported": True,
"reason": "imported",
"target_job_id": target_job_id,
"target_job_code": str(target_job.get("job_code") or ""),
"detect_run_event_id": detect_run_event_id,
"updated_job_items": updated_job_items,
"event_type": normalized_event["event_type"],
"domain": str((normalized_event.get("payload") or {}).get("domain") or ""),
}
@db_read_retry()
def list_debug_events(
limit: int = 50,
*,
service: str | None = None,
event_type: str | None = None,
source_region: str | None = None,
node_code: str | None = None,
level: str | None = None,
before_id: int | None = None,
after_id: int | None = None,
created_after: str | None = None,
) -> dict:
ensure_debug_event_schema()
safe_limit = max(1, min(int(limit or 50), 500))
conditions: list[str] = []
params: list[object] = []
if str(service or "").strip():
conditions.append("service = %s")
params.append(str(service).strip())
if str(event_type or "").strip():
conditions.append("event_type = %s")
params.append(str(event_type).strip())
if str(source_region or "").strip():
conditions.append("source_region = %s")
params.append(str(source_region).strip())
if str(node_code or "").strip():
conditions.append("node_code = %s")
params.append(str(node_code).strip())
if str(level or "").strip():
conditions.append("level = %s")
params.append(str(level).strip())
if before_id is not None and int(before_id or 0) > 0:
conditions.append("id < %s")
params.append(int(before_id))
if after_id is not None and int(after_id or 0) > 0:
conditions.append("id > %s")
params.append(int(after_id))
parsed_created_after = _parse_time(created_after)
if parsed_created_after:
conditions.append("created_at >= %s")
params.append(parsed_created_after)
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
f"""
SELECT id, source_region, node_code, service, event_type, level, message, payload_json, created_at
FROM detect_debug_events
{where_clause}
ORDER BY created_at DESC, id DESC
LIMIT %s
""",
(*params, safe_limit + 1),
)
rows = cur.fetchall()
has_more = len(rows) > safe_limit
rows = rows[:safe_limit]
records = [
{
"id": row[0],
"source_region": row[1],
"node_code": row[2],
"service": row[3],
"event_type": row[4],
"level": row[5],
"message": row[6],
"payload": row[7] if isinstance(row[7], dict) else {},
"created_at": _format_time(row[8]),
}
for row in rows
]
next_before_id = records[-1]["id"] if has_more and records else None
latest_id = records[0]["id"] if records else None
return {
"records": records,
"has_more": has_more,
"next_before_id": next_before_id,
"latest_id": latest_id,
}
@db_read_retry()
def get_debug_event_overview(*, window_minutes: int = 10, source_region: str | None = None) -> dict:
ensure_debug_event_schema()
safe_window = max(1, min(int(window_minutes or 10), 180))
conditions = ["created_at >= CURRENT_TIMESTAMP - (%s || ' minutes')::interval"]
params: list[object] = [safe_window]
if str(source_region or "").strip():
conditions.append("source_region = %s")
params.append(str(source_region).strip())
where_clause = f"WHERE {' AND '.join(conditions)}"
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
f"""
SELECT level, count(*)
FROM detect_debug_events
{where_clause}
GROUP BY level
""",
tuple(params),
)
level_counts = {str(level or "info"): int(count) for level, count in cur.fetchall()}
cur.execute(
f"""
SELECT service, event_type, level, count(*) AS total
FROM detect_debug_events
{where_clause}
GROUP BY service, event_type, level
ORDER BY total DESC, service ASC, event_type ASC
LIMIT 12
""",
tuple(params),
)
hot_events = [
{
"service": str(row[0] or ""),
"event_type": str(row[1] or ""),
"level": str(row[2] or "info"),
"count": int(row[3] or 0),
}
for row in cur.fetchall()
]
cur.execute(
f"""
SELECT
CASE
WHEN event_type LIKE 'task_pull%%' THEN 'task_pull'
WHEN event_type LIKE 'detect_result_projection_sync%%' THEN 'detect_result_sync'
WHEN event_type LIKE 'runtime_projection_sync%%' THEN 'runtime_projection_sync'
WHEN event_type LIKE 'sync_push%%' THEN 'sync_push'
WHEN event_type LIKE 'runtime_action%%' THEN 'runtime_action'
ELSE 'other'
END AS bucket,
count(*) AS total,
count(*) FILTER (WHERE level = 'warning') AS warnings,
count(*) FILTER (WHERE level = 'error') AS errors
FROM detect_debug_events
{where_clause}
GROUP BY bucket
ORDER BY total DESC, bucket ASC
""",
tuple(params),
)
trend_buckets = [
{
"bucket": str(row[0] or "other"),
"total": int(row[1] or 0),
"warnings": int(row[2] or 0),
"errors": int(row[3] or 0),
}
for row in cur.fetchall()
]
cur.execute(
f"""
SELECT id, source_region, node_code, service, event_type, level, message, payload_json, created_at
FROM detect_debug_events
{where_clause}
AND level IN ('warning', 'error')
ORDER BY created_at DESC, id DESC
LIMIT 8
""",
tuple(params),
)
recent_issues = [
{
"id": row[0],
"source_region": row[1],
"node_code": row[2],
"service": row[3],
"event_type": row[4],
"level": row[5],
"message": row[6],
"payload": row[7] if isinstance(row[7], dict) else {},
"created_at": _format_time(row[8]),
}
for row in cur.fetchall()
]
cur.execute(
f"""
SELECT
source_region,
service,
event_type,
level,
message,
count(*) AS total,
min(created_at) AS first_seen_at,
max(created_at) AS last_seen_at
FROM detect_debug_events
{where_clause}
AND level IN ('warning', 'error')
GROUP BY source_region, service, event_type, level, message
ORDER BY total DESC, last_seen_at DESC
LIMIT 12
""",
tuple(params),
)
issue_groups = [
{
"source_region": str(row[0] or ""),
"service": str(row[1] or ""),
"event_type": str(row[2] or ""),
"level": str(row[3] or "info"),
"message": str(row[4] or ""),
"count": int(row[5] or 0),
"first_seen_at": _format_time(row[6]),
"last_seen_at": _format_time(row[7]),
"risk_score": int(row[5] or 0) * (3 if str(row[3] or "info") == "error" else 2),
"risk_label": "high" if (int(row[5] or 0) >= 3 and str(row[3] or "info") == "error") else ("medium" if int(row[5] or 0) >= 3 or str(row[3] or "info") == "error" else "low"),
}
for row in cur.fetchall()
]
total = sum(level_counts.values())
return {
"window_minutes": safe_window,
"source_region": str(source_region or "").strip(),
"total": total,
"level_counts": {
"info": int(level_counts.get("info", 0)),
"warning": int(level_counts.get("warning", 0)),
"error": int(level_counts.get("error", 0)),
},
"trend_buckets": trend_buckets,
"hot_events": hot_events,
"recent_issues": recent_issues,
"issue_groups": issue_groups,
}
def get_debug_diagnosis(*, window_minutes: int = 10, source_region: str | None = None, sync_summary: dict | None = None) -> dict:
overview = get_debug_event_overview(window_minutes=window_minutes, source_region=source_region)
summary = sync_summary or {}
latest_record = summary.get("latest_record") or {}
latest_result_batch = summary.get("latest_detect_result_batch") or {}
detect_result_batches = summary.get("detect_result_batches") or {}
projected = int((detect_result_batches.get("state_counts") or {}).get("projected", 0) or 0)
bucket_map = {
str(item.get("bucket") or "other"): item
for item in (overview.get("trend_buckets") or [])
if isinstance(item, dict)
}
task_pull = bucket_map.get("task_pull", {})
sync_push = bucket_map.get("sync_push", {})
result_sync = bucket_map.get("detect_result_sync", {})
runtime_action = bucket_map.get("runtime_action", {})
def _build(
*,
status: str,
title: str,
summary_text: str,
suspect: str,
evidence: str,
action: str,
next_step: str,
confidence: str,
suggested_checks: list[str],
recommended_action_api: str,
) -> dict:
return {
"status": status,
"title": title,
"summary": summary_text,
"suspect": suspect,
"evidence": evidence,
"action": action,
"next_step": next_step,
"confidence": confidence,
"suggested_checks": suggested_checks,
"recommended_action_api": recommended_action_api,
"window_minutes": int(overview.get("window_minutes", window_minutes) or window_minutes),
"source_region": str(source_region or "").strip(),
}
if int(result_sync.get("errors", 0) or 0) > 0 or str(latest_result_batch.get("latest_push_status") or "").strip().lower() == "failed":
return _build(
status="danger",
title="结果同步失败",
summary_text="任务执行可能仍在继续,但检测结果跨地域回传已经出现明确失败。",
suspect="sync-agent 到海外 ingest / 目标接收面",
evidence=f"detect_result_sync errors={int(result_sync.get('errors', 0) or 0)}latest_push_status={str(latest_result_batch.get('latest_push_status') or '-')}",
action="优先检查结果推送链路",
next_step="先查看 latest_push_error再核对海外端 /runtime/sync-ingest 接收和网络连通性。",
confidence="high",
suggested_checks=[
"curl -s http://127.0.0.1:8100/api/v1/runtime/sync-summary",
"journalctl -u domaincheck-sync-agent -n 50 --no-pager",
"curl -s https://api-domain.aakkx.top/api/v1/runtime/readiness",
],
recommended_action_api="push_sync",
)
if projected > 0 or int(result_sync.get("warnings", 0) or 0) > 0:
return _build(
status="warning",
title="结果同步滞留",
summary_text="结果投影已经生成,但尚未稳定进入 delivered/synced。",
suspect="sync-agent 推送窗口 / 远端 ack / 目标侧消费速度",
evidence=f"projected={projected}detect_result_sync warnings={int(result_sync.get('warnings', 0) or 0)}",
action="优先检查 sync-agent 与远端确认",
next_step="结合结果同步摘要中的最近 Push/Ingress 时间,确认是卡在推送前还是目标接收后。",
confidence="medium",
suggested_checks=[
"curl -s http://127.0.0.1:8100/api/v1/runtime/sync-summary",
"curl -s -X POST http://127.0.0.1:8100/api/v1/runtime/actions/push_sync",
"journalctl -u domaincheck-sync-agent -n 50 --no-pager",
],
recommended_action_api="push_sync",
)
if int(task_pull.get("errors", 0) or 0) > 0 or int(task_pull.get("warnings", 0) or 0) > 0:
return _build(
status="warning",
title="任务拉取链路异常",
summary_text="控制面最近从海外拉取待检测任务时出现失败或半成功。",
suspect="task-export / task-ack / controller 入库",
evidence=f"task_pull warnings={int(task_pull.get('warnings', 0) or 0)} errors={int(task_pull.get('errors', 0) or 0)}",
action="优先检查任务拉取与确认",
next_step="筛选 task_pull_success/partial/failed确认是 export、ack 还是本地 ingest 失败。",
confidence="high" if int(task_pull.get("errors", 0) or 0) > 0 else "medium",
suggested_checks=[
"curl -s -X POST http://127.0.0.1:8100/api/v1/runtime/actions/pull_tasks",
"curl -s http://127.0.0.1:8100/api/v1/runtime/debug-overview",
"journalctl -u domaincheck-api -n 80 --no-pager",
],
recommended_action_api="pull_tasks",
)
if int(runtime_action.get("errors", 0) or 0) > 0 or int(runtime_action.get("warnings", 0) or 0) > 0:
return _build(
status="warning",
title="控制动作链路波动",
summary_text="运行时动作请求已发出,但 controller 对 worker/api/sync-agent 的控制链路近期不稳定。",
suspect="systemctl 权限 / 本机环境 / 控制信道",
evidence=f"runtime_action warnings={int(runtime_action.get('warnings', 0) or 0)} errors={int(runtime_action.get('errors', 0) or 0)}",
action="优先检查控制动作执行侧",
next_step="对比 runtime_action_requested/runtime_action_finished确认是否请求到了但执行失败。",
confidence="medium",
suggested_checks=[
"curl -s http://127.0.0.1:8100/api/v1/runtime/status",
"systemctl status domaincheck-worker --no-pager -l",
"journalctl -u domaincheck-api -n 80 --no-pager",
],
recommended_action_api="",
)
if str(latest_record.get("status") or "").strip().lower() == "failed" or int(sync_push.get("errors", 0) or 0) > 0:
return _build(
status="warning",
title="运行态同步异常",
summary_text="控制面心跳/运行态同步最近出现失败,可能影响海外端看到的集群状态。",
suspect="runtime_projection 推送 / 海外 runtime ingest",
evidence=f"latest_record_status={str(latest_record.get('status') or '-')}sync_push errors={int(sync_push.get('errors', 0) or 0)}",
action="优先检查运行态同步",
next_step="筛选 runtime_projection_sync_failed检查海外 /runtime/sync-ingest 是否正常接收。",
confidence="medium",
suggested_checks=[
"curl -s http://127.0.0.1:8100/api/v1/runtime/readiness",
"curl -s http://127.0.0.1:8100/api/v1/runtime/sync-summary",
"journalctl -u domaincheck-sync-agent -n 50 --no-pager",
],
recommended_action_api="push_sync",
)
return _build(
status="ok",
title="当前链路整体稳定",
summary_text="任务拉取、运行态同步、结果同步和控制动作近窗内都没有明显异常。",
suspect="暂无明确阻塞段",
evidence=(
f"task_pull={int(task_pull.get('total', 0) or 0)} "
f"sync_push={int(sync_push.get('total', 0) or 0)} "
f"detect_result_sync={int(result_sync.get('total', 0) or 0)}"
),
action="继续观察即可",
next_step="如果要排更细问题,优先看结果同步摘要和最近问题列表。",
confidence="medium",
suggested_checks=[
"curl -s http://127.0.0.1:8100/api/v1/runtime/readiness",
"curl -s http://127.0.0.1:8100/api/v1/runtime/debug-overview",
],
recommended_action_api="",
)
def get_debug_handoff_report(
*,
window_minutes: int = 10,
source_region: str | None = None,
sync_summary: dict | None = None,
readiness: dict | None = None,
) -> dict:
summary = sync_summary or {}
overview = get_debug_event_overview(window_minutes=window_minutes, source_region=source_region)
diagnosis = get_debug_diagnosis(window_minutes=window_minutes, source_region=source_region, sync_summary=summary)
readiness_payload = readiness or {}
latest_record = summary.get("latest_record") or {}
latest_result_batch = summary.get("latest_detect_result_batch") or {}
level_counts = overview.get("level_counts") or {}
trend_buckets = overview.get("trend_buckets") or []
recent_issues = overview.get("recent_issues") or []
issue_groups = overview.get("issue_groups") or []
lines = [
f"诊断结论: {diagnosis.get('title', '-')}",
f"状态: {diagnosis.get('status', '-')} | 置信度: {diagnosis.get('confidence', '-')}",
f"摘要: {diagnosis.get('summary', '-')}",
f"优先怀疑: {diagnosis.get('suspect', '-')}",
f"证据: {diagnosis.get('evidence', '-')}",
f"建议动作: {diagnosis.get('action', '-')}",
f"下一步: {diagnosis.get('next_step', '-')}",
f"推荐 API 动作: {diagnosis.get('recommended_action_api', '-') or '-'}",
f"集群就绪: {readiness_payload.get('status', '-')}/{readiness_payload.get('summary', '-')}",
"",
f"{overview.get('window_minutes', window_minutes)} 分钟事件: total={overview.get('total', 0)} info={level_counts.get('info', 0)} warning={level_counts.get('warning', 0)} error={level_counts.get('error', 0)}",
"趋势桶: " + (" | ".join(
f"{item.get('bucket', 'other')} total={item.get('total', 0)} warn={item.get('warnings', 0)} err={item.get('errors', 0)}"
for item in trend_buckets
) or "-"),
f"最新同步记录: {latest_record.get('sync_type', '-')}/{latest_record.get('status', '-')}/{latest_record.get('created_at', '-')}",
f"最新结果批次: {latest_result_batch.get('job_code', '-')}/{latest_result_batch.get('sync_state', '-')}/push={latest_result_batch.get('latest_push_status', '-')}/ingest={latest_result_batch.get('latest_ingest_status', '-')}",
"最近问题:",
]
if recent_issues:
for issue in recent_issues[:5]:
lines.append(
f"- [{issue.get('created_at', '-')}] {issue.get('service', '-')}/{issue.get('event_type', '-')} {issue.get('level', '-')} {issue.get('message', '-')}"
)
else:
lines.append("- 近窗内没有 warning/error")
checks = diagnosis.get("suggested_checks") or []
lines.append("建议命令:")
if checks:
for cmd in checks:
lines.append(f"- {cmd}")
else:
lines.append("- 暂无")
compact_lines = [
f"[{diagnosis.get('status', '-')}] {diagnosis.get('title', '-')}",
f"摘要: {diagnosis.get('summary', '-')}",
f"怀疑点: {diagnosis.get('suspect', '-')}",
f"动作: {diagnosis.get('action', '-')} | API: {diagnosis.get('recommended_action_api', '-') or '-'}",
f"证据: {diagnosis.get('evidence', '-')}",
f"就绪: {readiness_payload.get('status', '-')}/{readiness_payload.get('summary', '-')}",
]
if issue_groups:
compact_lines.append("重复问题:")
for issue in issue_groups[:3]:
compact_lines.append(
f"- {issue.get('service', '-')}/{issue.get('event_type', '-')} "
f"{issue.get('level', '-')} x{issue.get('count', 0)}: {issue.get('message', '-')}"
)
else:
compact_lines.append("重复问题: 近窗内没有 warning/error 聚集")
compact_lines.append("建议命令:")
if checks:
for cmd in checks[:3]:
compact_lines.append(f"- {cmd}")
else:
compact_lines.append("- 暂无")
failure_handoff = {
"generated_from": "runtime/debug-handoff",
"source_region": str(source_region or "").strip(),
"window_minutes": overview.get("window_minutes", window_minutes),
"diagnosis": diagnosis,
"readiness": readiness_payload,
"latest_record": latest_record,
"latest_detect_result_batch": latest_result_batch,
"issue_groups": issue_groups[:8],
"recent_issues": recent_issues[:8],
"suggested_checks": checks,
}
return {
"window_minutes": overview.get("window_minutes", window_minutes),
"source_region": str(source_region or "").strip(),
"diagnosis": diagnosis,
"overview": overview,
"issue_groups": issue_groups,
"readiness": readiness_payload,
"sync_summary": {
"latest_record": latest_record,
"latest_detect_result_batch": latest_result_batch,
},
"failure_handoff": failure_handoff,
"compact_report_text": "\n".join(compact_lines),
"report_text": "\n".join(lines),
}
def ingest_debug_event(payload: dict, *, shared_token: str | None = None) -> tuple[bool, str, dict]:
configured_token = str(settings.sync_shared_token or "").strip()
incoming_token = str(shared_token or "").strip()
if not configured_token:
return False, "调试事件共享 token 未配置,拒绝远端写入", {"configuration_required": True}
if incoming_token != configured_token:
return False, "调试事件 token 校验失败", {}
record_id = append_debug_event(
source_region=str(payload.get("source_region") or settings.node_region),
node_code=str(payload.get("node_code") or ""),
service=str(payload.get("service") or "unknown"),
event_type=str(payload.get("event_type") or "event"),
level=str(payload.get("level") or "info"),
message=_normalize_message(payload.get("message"), fallback="remote debug event"),
payload=payload.get("payload") or {},
)
debug_event = _load_debug_event_record(record_id) or {
"id": int(record_id),
"source_region": str(payload.get("source_region") or settings.node_region),
"node_code": str(payload.get("node_code") or ""),
"service": str(payload.get("service") or "unknown"),
"event_type": str(payload.get("event_type") or "event"),
"level": str(payload.get("level") or "info"),
"message": _normalize_message(payload.get("message"), fallback="remote debug event"),
"payload": payload.get("payload") or {},
"created_at": "",
}
job_import = {}
if str(debug_event.get("service") or "").strip() == "worker-event":
try:
job_import = _ingest_worker_log_into_active_job(debug_event)
except Exception as exc:
job_import = {"imported": False, "reason": f"job_import_failed: {exc}"}
return True, "调试事件接收成功", {"record_id": record_id, "job_import": job_import}
def push_debug_event(
*,
service: str,
event_type: str,
message: str,
level: str = "info",
payload: dict | None = None,
) -> tuple[bool, str, dict]:
append_debug_event(
service=service,
event_type=event_type,
message=message,
level=level,
payload=payload,
)
if not settings.sync_push_enabled:
return False, "未启用调试事件远端回传", {"local_only": True}
ingest_url = _debug_ingest_url(settings.sync_target_api_base_url)
if not ingest_url:
return False, "未配置调试事件远端地址", {"local_only": True}
request_payload = {
"source_region": settings.node_region,
"node_code": settings.node_code,
"hostname": socket.gethostname(),
"service": str(service or "").strip() or "unknown",
"event_type": str(event_type or "").strip() or "event",
"level": str(level or "info").strip() or "info",
"message": _normalize_message(message, fallback=f"{service}/{event_type}"),
"payload": payload or {},
}
request = urllib.request.Request(
ingest_url,
data=_safe_json_dumps(request_payload).encode("utf-8"),
headers={
"Content-Type": "application/json",
**({"X-Domaincheck-Sync-Token": settings.sync_shared_token} if settings.sync_shared_token else {}),
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
raw = response.read().decode("utf-8")
response_data = json.loads(raw) if raw else {}
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="ignore") if hasattr(exc, "read") else ""
return False, f"调试事件远端回传失败: HTTP {getattr(exc, 'code', 500)}", {
"http_status": getattr(exc, "code", 500),
"response_text": error_body[:500],
}
except json.JSONDecodeError as exc:
return False, f"调试事件远端回传失败: 响应不是合法 JSON ({exc})", {}
except Exception as exc:
return False, f"调试事件远端回传失败: {exc}", {}
if not _response_code_ok(response_data.get("code")):
return False, str(response_data.get("message") or "调试事件远端回传失败"), {"response": response_data}
return True, "调试事件已回传海外", {"response": response_data}