Files
Jyotisha/deploy/monitor/classify_error_log.py
T
jesse-uxandGrok (agent loop) 53d8df257d 给内测补上每日加密备份、宕机告警和日志上限。
主机安装和一次恢复演练留给产品。页面没有变化。

Co-Authored-By: Grok (agent loop)
2026-10-15 12:58:34 +08:00

161 lines
5.9 KiB
Python

"""Classify fictional or container log lines into alert rows.
Outbound fields are service, route, errorCode, count, and request ids.
Lines with non-ASCII text, an email address, or a second calendar date are
dropped so conversation text and birth dates cannot leave the host.
"""
from __future__ import annotations
import json
import re
import sys
from collections import OrderedDict
from datetime import datetime, timedelta, timezone
EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
DATE = re.compile(r"\b\d{4}-\d{2}-\d{2}\b")
UUID = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
ERROR_CODE = re.compile(r"^[a-z][a-z0-9._-]{0,79}$")
ROUTE = re.compile(r"^/[A-Za-z0-9/_.-]{0,120}$")
STATUS = re.compile(r"(?:statusCode|status)[\"']?\s*[:=]\s*(\d{3})\b")
FIXTURE = re.compile(
r"^(?P<ts>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z) (?P<svc>web|api) (?P<rest>.*)$"
)
DOCKER = re.compile(
r"^(?P<svc>web|api)-\d+\s+\|\s+"
r"(?P<ts>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z) (?P<rest>.*)$"
)
CST = timezone(timedelta(hours=8))
def unsafe(line: str) -> bool:
if any(ord(char) > 126 for char in line):
return True
lowered = line.lower()
if EMAIL.search(line) or "birthdate" in lowered or "birth_date" in lowered:
return True
return len(DATE.findall(line)) >= 2
def parse_line(line: str) -> tuple[str, str, str] | None:
match = FIXTURE.match(line) or DOCKER.match(line)
if not match:
return None
return match.group("ts"), match.group("svc"), match.group("rest")
def timestamp_cst_date(timestamp: str):
stamp = timestamp
if "." in stamp:
stamp = stamp.split(".", 1)[0] + "Z"
moment = datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
return moment.astimezone(CST).date()
def extract_fields(rest: str) -> tuple[str, str, str, int | None]:
route = "-"
error_code = ""
request_id = ""
status: int | None = None
marker = "[agent-observability]"
if marker in rest:
raw = rest.split(marker, 1)[1].strip()
try:
payload = json.loads(raw)
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict):
code = payload.get("errorCode")
if isinstance(code, str) and ERROR_CODE.match(code):
error_code = code
candidate = payload.get("requestId")
if isinstance(candidate, str) and UUID.match(candidate):
request_id = candidate.lower()
route_value = payload.get("route")
if isinstance(route_value, str) and ROUTE.match(route_value):
route = route_value
status_match = STATUS.search(rest)
if status_match:
status = int(status_match.group(1))
return route, error_code, request_id, status
def classify(lines: list[str], mode: str, now_text: str, error_threshold: int, fivexx_threshold: int) -> list[str]:
now = datetime.strptime(now_text, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
yesterday = now.astimezone(CST).date() - timedelta(days=1)
counts: OrderedDict[tuple[str, str, str], int] = OrderedDict()
request_ids: OrderedDict[tuple[str, str, str], list[str]] = OrderedDict()
fivexx_counts: OrderedDict[str, int] = OrderedDict()
fivexx_ids: OrderedDict[str, list[str]] = OrderedDict()
def add(key: tuple[str, str, str], request_id: str) -> None:
counts[key] = counts.get(key, 0) + 1
bucket = request_ids.setdefault(key, [])
if request_id and request_id not in bucket and len(bucket) < 5:
bucket.append(request_id)
for raw in lines:
line = raw.rstrip("\r\n")
if not line or unsafe(line):
continue
parsed = parse_line(line)
if parsed is None:
continue
timestamp, service, rest = parsed
if mode == "daily" and timestamp_cst_date(timestamp) != yesterday:
continue
route, error_code, request_id, status = extract_fields(rest)
if error_code:
add((service, route, error_code), request_id)
elif status is not None:
add((service, route, f"http_{status}"), request_id)
if status is not None and 500 <= status <= 599:
fivexx_counts[service] = fivexx_counts.get(service, 0) + 1
bucket = fivexx_ids.setdefault(service, [])
if request_id and request_id not in bucket and len(bucket) < 5:
bucket.append(request_id)
rows: list[str] = []
def emit(service: str, route: str, code: str, count: int, ids: list[str]) -> None:
shown = ",".join(ids) if ids else "-"
rows.append(f"{service}\t{route}\t{code}\t{count}\t{shown}")
for key, count in counts.items():
if mode == "window" and count < error_threshold:
continue
if count <= 0:
continue
emit(*key, count, request_ids.get(key, []))
for service, count in fivexx_counts.items():
if mode == "window" and count < fivexx_threshold:
continue
if count <= 0:
continue
emit(service, "-", "http_5xx", count, fivexx_ids.get(service, []))
return rows
def main(argv: list[str]) -> int:
if len(argv) != 5:
print("usage: classify_error_log.py <fixture> <window|daily> <now> <error-threshold> <5xx-threshold>", file=sys.stderr)
return 2
fixture, mode, now_text, error_threshold_text, fivexx_threshold_text = argv
if mode not in {"window", "daily"}:
print("unknown mode", file=sys.stderr)
return 2
error_threshold = int(error_threshold_text)
fivexx_threshold = int(fivexx_threshold_text)
with open(fixture, encoding="utf-8") as handle:
lines = handle.readlines()
for row in classify(lines, mode, now_text, error_threshold, fivexx_threshold):
print(row)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))