Files
loogle-scripts/services/telegram-bot/log_monitor.py
T

297 lines
11 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import datetime
import os
import re
from pathlib import Path
from collections import defaultdict, deque
from typing import Dict, List, Optional, Tuple
import requests
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DEFAULT_PATTERNS = ["*.log", "*_log.txt"]
EXCLUDED_FILES = {
"circondario_log.txt",
"irrigation_cron.log",
"road_weather.log",
"snow_radar.log",
}
SEASONAL_EXCLUDED_FILES = {
"freeze_alert.log",
"arome_snow_alert.log",
"meteo.log",
}
SEASONAL_EXCLUDED_MONTHS = {5, 6, 7, 8, 9} # maggio-settembre
# Log irrigazione: aggiornato solo quando lo script viene eseguito (cron --auto o /irrigazione).
# Se non c’è un cron giornaliero, il file può restare “non aggiornato” per giorni (normale in inverno).
STALE_EXCLUDE_BASENAMES = {"irrigation_advisor.log"}
TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token")
TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token"
TS_RE = re.compile(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})")
CATEGORIES = {
"open_meteo_timeout": re.compile(
r"timeout|timed out|Read timed out|Gateway Time-out|HTTP\s*504|status\s*504|\b504\b",
re.IGNORECASE,
),
"ssl_handshake": re.compile(r"handshake", re.IGNORECASE),
"permission_error": re.compile(r"PermissionError|permesso negato|Errno 13", re.IGNORECASE),
"telegram_error": re.compile(r"Telegram error|Bad Request|chat not found|can't parse entities", re.IGNORECASE),
"traceback": re.compile(r"Traceback", re.IGNORECASE),
"exception": re.compile(r"\bERROR\b|Exception", re.IGNORECASE),
# Solo token realmente assente (non i messaggi ambigui con Telegram sospeso)
"token_missing": re.compile(
r"Telegram token missing|Token Telegram assente|Token Telegram mancante",
re.IGNORECASE,
),
}
# Comportamento atteso con LOOGLE_TELEGRAM_ALERTS=0 / canale WebApp: non sono problemi.
IGNORE_ISSUE_PATTERNS = [
re.compile(r"Telegram sospeso", re.IGNORECASE),
re.compile(r"Alert NON inviato \(token missing o errore Telegram\)", re.IGNORECASE),
re.compile(r"NOT sent \(token missing or Telegram error\)", re.IGNORECASE),
re.compile(r"Notifica NON inviata \(token/telegram\)", re.IGNORECASE),
re.compile(r"token missing o errore Telegram", re.IGNORECASE),
re.compile(r"token mancante o errore Telegram", re.IGNORECASE),
re.compile(r"Telegram saltato/sospeso", re.IGNORECASE),
re.compile(r"All-clear Telegram skip/fail", re.IGNORECASE),
re.compile(r"publish_web_alert failed", re.IGNORECASE), # gestito sotto come web_notify se serve
]
def telegram_alerts_suspended() -> bool:
env_path = os.path.join(BASE_DIR, "cron-alerts.env")
try:
with open(env_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line.startswith("LOOGLE_TELEGRAM_ALERTS="):
val = line.split("=", 1)[1].strip().strip('"').strip("'").lower()
return val in ("0", "off", "false", "no", "disabled")
except OSError:
pass
val = os.environ.get("LOOGLE_TELEGRAM_ALERTS", "1").strip().lower()
return val in ("0", "off", "false", "no", "disabled")
def should_ignore_issue_line(line: str) -> bool:
return any(p.search(line) for p in IGNORE_ISSUE_PATTERNS)
def load_text_file(path: str) -> str:
try:
with open(path, "r", encoding="utf-8") as f:
return f.read().strip()
except Exception:
return ""
def load_bot_token() -> str:
tok = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
if tok:
return tok
tok = load_text_file(TOKEN_FILE_HOME)
if tok:
return tok
tok = load_text_file(TOKEN_FILE_ETC)
return tok.strip() if tok else ""
def send_telegram(text: str, chat_ids: Optional[List[str]]) -> bool:
token = load_bot_token()
if not token or not chat_ids:
return False
url = f"https://api.telegram.org/bot{token}/sendMessage"
base_payload = {
"text": text,
"disable_web_page_preview": True,
}
ok = False
with requests.Session() as s:
for chat_id in chat_ids:
payload = dict(base_payload)
payload["chat_id"] = chat_id
try:
resp = s.post(url, json=payload, timeout=15)
if resp.status_code == 200:
ok = True
except Exception:
continue
return ok
def tail_lines(path: str, max_lines: int) -> List[str]:
dq: deque[str] = deque(maxlen=max_lines)
with open(path, "r", encoding="utf-8", errors="ignore") as f:
for line in f:
dq.append(line.rstrip("\n"))
return list(dq)
def parse_ts(line: str) -> Optional[datetime.datetime]:
m = TS_RE.search(line)
if not m:
return None
try:
return datetime.datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S")
except Exception:
return None
def analyze_logs(files: List[str], since: datetime.datetime, max_lines: int) -> Tuple[Dict, Dict, Dict, Dict]:
category_hits = defaultdict(list)
per_file_counts = defaultdict(lambda: defaultdict(int))
timeout_minutes = defaultdict(int)
stale_logs = {} # path -> (last_ts, hours_since_update)
now = datetime.datetime.now()
for path in files:
if not os.path.isfile(path):
continue
last_ts = None
for line in tail_lines(path, max_lines):
ts = parse_ts(line)
if ts:
last_ts = ts
if not last_ts or last_ts < since:
continue
if should_ignore_issue_line(line):
continue
for cat, regex in CATEGORIES.items():
if regex.search(line):
category_hits[cat].append((last_ts, path, line))
per_file_counts[path][cat] += 1
if cat == "open_meteo_timeout":
timeout_minutes[last_ts.strftime("%H:%M")] += 1
break
# Verifica se il log è "stale" (non aggiornato da più di 24 ore)
if os.path.basename(path) in STALE_EXCLUDE_BASENAMES:
continue # Non segnalare come stale (es. irrigazione: aggiornato solo a ogni run)
if last_ts:
hours_since = (now - last_ts).total_seconds() / 3600.0
if hours_since > 24:
stale_logs[path] = (last_ts, hours_since)
else:
# Se non ha timestamp, verifica data di modifica del file
try:
mtime = os.path.getmtime(path)
file_mtime = datetime.datetime.fromtimestamp(mtime)
hours_since = (now - file_mtime).total_seconds() / 3600.0
if hours_since > 24:
stale_logs[path] = (file_mtime, hours_since)
except Exception:
pass
return category_hits, per_file_counts, timeout_minutes, stale_logs
def format_report(
days: int,
files: List[str],
category_hits: Dict,
per_file_counts: Dict,
timeout_minutes: Dict,
stale_logs: Dict,
) -> str:
now = datetime.datetime.now()
since = now - datetime.timedelta(days=days)
lines = []
lines.append(f"🧾 Log Monitor - ultimi {days} giorni")
lines.append(f"Intervallo: {since.strftime('%Y-%m-%d %H:%M')}{now.strftime('%Y-%m-%d %H:%M')}")
lines.append(f"File analizzati: {len(files)}")
if telegram_alerts_suspended():
lines.append("️ Telegram sospeso (LOOGLE_TELEGRAM_ALERTS=0): canale primario WebApp Casa")
lines.append("")
# Sezione log non aggiornati
if stale_logs:
lines.append("⚠️ Log non aggiornati (>24h):")
for path, (last_ts, hours_since) in sorted(stale_logs.items(), key=lambda x: x[1][1], reverse=True):
short_path = os.path.basename(path)
days_ago = hours_since / 24.0
if days_ago >= 1:
lines.append(f" • {short_path}: {days_ago:.1f} giorni fa ({last_ts.strftime('%Y-%m-%d %H:%M')})")
else:
lines.append(f" • {short_path}: {hours_since:.1f} ore fa ({last_ts.strftime('%Y-%m-%d %H:%M')})")
lines.append("")
total_issues = sum(len(v) for v in category_hits.values())
if total_issues == 0 and not stale_logs:
lines.append("✅ Nessun problema rilevato nelle ultime 72 ore.")
return "\n".join(lines)
lines.append(f"Problemi rilevati: {total_issues}")
lines.append("")
for cat, items in sorted(category_hits.items(), key=lambda x: len(x[1]), reverse=True):
lines.append(f"- {cat}: {len(items)}")
# show up to 3 latest samples
for ts, path, msg in sorted(items, key=lambda x: x[0], reverse=True)[:3]:
short_path = os.path.basename(path)
lines.append(f" • {ts.strftime('%Y-%m-%d %H:%M:%S')} | {short_path} | {msg[:180]}")
lines.append("")
if timeout_minutes:
lines.append("Timeout: minuti più frequenti")
for minute, count in sorted(timeout_minutes.items(), key=lambda x: x[1], reverse=True)[:6]:
lines.append(f" • {minute} -> {count}")
lines.append("")
# Per-file summary (top 6 files)
lines.append("File più problematici")
file_totals = []
for path, cats in per_file_counts.items():
file_totals.append((sum(cats.values()), path))
for total, path in sorted(file_totals, reverse=True)[:6]:
short_path = os.path.basename(path)
lines.append(f" • {short_path}: {total}")
return "\\n".join(lines)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--days", type=int, default=3, help="Numero di giorni da analizzare")
parser.add_argument("--max-lines", type=int, default=5000, help="Limite righe per file")
parser.add_argument("--chat_id", help="Chat ID Telegram (separati da virgola)")
parser.add_argument("--log", action="append", help="Aggiungi un file log specifico")
args = parser.parse_args()
run_ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"--- Log Monitor run {run_ts} ---")
if args.log:
files = [p for p in args.log if os.path.exists(p)]
else:
from pathlib import Path
files = []
for pat in DEFAULT_PATTERNS:
files.extend(sorted([str(p) for p in Path(BASE_DIR).glob(pat)]))
files = sorted(set(files))
now_month = datetime.datetime.now().month
seasonal_exclusions = SEASONAL_EXCLUDED_FILES if now_month in SEASONAL_EXCLUDED_MONTHS else set()
excluded_names = EXCLUDED_FILES | seasonal_exclusions
files = [p for p in files if os.path.basename(p) not in excluded_names]
since = datetime.datetime.now() - datetime.timedelta(days=args.days)
category_hits, per_file_counts, timeout_minutes, stale_logs = analyze_logs(files, since, args.max_lines)
report = format_report(args.days, files, category_hits, per_file_counts, timeout_minutes, stale_logs)
if args.chat_id:
chat_ids = [c.strip() for c in args.chat_id.split(",") if c.strip()]
send_telegram(report, chat_ids)
else:
print(report)
if __name__ == "__main__":
main()