297 lines
11 KiB
Python
297 lines
11 KiB
Python
#!/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()
|