wifi-etl/app/watchdog.py

317 lines
9.9 KiB
Python
Raw Normal View History

import os
import smtplib
import logging
from datetime import datetime, timezone, timedelta
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import psycopg2
from app.core.config import (
DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD,
MAIL_HOST, MAIL_PORT, MAIL_FROM,
WATCHDOG_RECIPIENT_EMAILS, WATCHDOG_CC_EMAILS,
)
STALE_THRESHOLD_H = 1
COOLDOWN_H = 2
COOLDOWN_FILE = '/tmp/etl_alert_sent'
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s: %(message)s')
log = logging.getLogger(__name__)
def get_last_run():
conn = psycopg2.connect(
host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
user=DB_USER, password=DB_PASSWORD, connect_timeout=5,
)
try:
cur = conn.cursor()
cur.execute("SELECT source, last_run_at FROM watermarks ORDER BY last_run_at DESC LIMIT 1")
return cur.fetchone()
finally:
conn.close()
def in_cooldown():
if not os.path.exists(COOLDOWN_FILE):
return False
mtime = datetime.fromtimestamp(os.path.getmtime(COOLDOWN_FILE), tz=timezone.utc)
return (datetime.now(timezone.utc) - mtime) < timedelta(hours=COOLDOWN_H)
def mark_cooldown():
with open(COOLDOWN_FILE, 'w') as f:
f.write(datetime.now(timezone.utc).isoformat())
def clear_cooldown():
if os.path.exists(COOLDOWN_FILE):
os.remove(COOLDOWN_FILE)
def build_html(source, last_run_at, horas, mins):
last_run_brt = last_run_at - timedelta(hours=3)
last_run_str = last_run_brt.strftime('%d/%m %H:%M BRT')
tempo_str = f"{horas}h{mins:02d}min"
return f"""<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {{
margin: 0; padding: 32px 16px;
background: #E8EDE9;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}}
.email {{
max-width: 580px; margin: 0 auto;
background: #fff; border-radius: 4px; overflow: hidden;
box-shadow: 0 2px 16px rgba(0,66,30,.12);
}}
.hd {{
background: #00843D; padding: 28px 36px 24px;
display: flex; align-items: center; justify-content: space-between;
}}
.hd-eyebrow {{
font-size: 10px; letter-spacing: .18em; text-transform: uppercase;
color: rgba(255,255,255,.65); font-weight: 600; display: block; margin-bottom: 4px;
}}
.hd-name {{
font-size: 20px; font-weight: 800; color: #fff; letter-spacing: .04em;
}}
.hd-icon {{ font-size: 38px; line-height: 1; }}
.banner {{
background: #FFF8E1; border-top: 3px solid #F59E0B;
border-bottom: 1px solid #FDE68A; padding: 14px 36px;
font-size: 12px; font-weight: 700; letter-spacing: .06em;
text-transform: uppercase; color: #92400E;
}}
.body {{ padding: 32px 36px 0; }}
.headline {{
font-size: 22px; font-weight: 800; color: #0D2118;
line-height: 1.25; margin: 0 0 12px;
}}
.headline span {{ color: #00843D; }}
.subline {{
font-size: 14px; line-height: 1.65; color: #3D5C48;
margin: 0 0 28px;
}}
.stats {{
display: table; width: 100%; border-collapse: collapse;
border: 1px solid #D4E6DA; border-radius: 6px;
overflow: hidden; margin-bottom: 28px;
}}
.stat {{
display: table-cell; background: #fff;
padding: 14px 16px; border-right: 1px solid #D4E6DA;
width: 33.33%;
}}
.stat:last-child {{ border-right: none; }}
.stat-label {{
font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
color: #6B8C7A; font-weight: 600; display: block; margin-bottom: 3px;
}}
.stat-value {{ font-size: 15px; font-weight: 700; color: #0D2118; }}
.stat-value.bad {{ color: #C0392B; }}
.cmd-block {{
background: #F2F7F4; border: 1px solid #C8DDD1;
border-radius: 6px; padding: 16px 20px; margin-bottom: 28px;
}}
.cmd-label {{
font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
color: #6B8C7A; font-weight: 600; margin-bottom: 10px;
}}
.cmd {{
font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
font-size: 12px; color: #00843D; line-height: 1.9;
}}
.prompt {{ color: #A0B8A8; }}
.note {{
font-size: 13px; color: #5A7A63; line-height: 1.6;
font-style: italic; padding-bottom: 32px;
}}
.footer {{
background: #F2F7F4; border-top: 1px solid #D4E6DA;
padding: 18px 36px; display: flex;
align-items: center; justify-content: space-between;
}}
.footer-text {{ font-size: 11px; color: #8AAD98; line-height: 1.5; }}
.badge {{
font-size: 10px; letter-spacing: .08em; text-transform: uppercase;
color: #fff; background: #00843D; padding: 4px 8px;
border-radius: 3px; font-weight: 700;
}}
</style>
</head>
<body>
<div class="email">
<div class="hd">
<div>
<span class="hd-eyebrow">Monitoramento</span>
<span class="hd-name">Heineken House</span>
</div>
<span class="hd-icon">🍺</span>
</div>
<div class="banner"> Pipeline parado ação necessária</div>
<div class="body">
<h1 class="headline">
Pausa na breja e nos drinks.<br>
<span>O ETL caiu.</span>
</h1>
<p class="subline">
O pipeline de dados do WiFi da Heineken House está parado mais de
{STALE_THRESHOLD_H}h. Os dados do dashboard podem estar desatualizados.
uma olhada quando puder.
</p>
<table class="stats">
<tr>
<td class="stat">
<span class="stat-label">Última fonte</span>
<span class="stat-value">{source}</span>
</td>
<td class="stat">
<span class="stat-label">Última execução</span>
<span class="stat-value">{last_run_str}</span>
</td>
<td class="stat">
<span class="stat-label">Tempo parado</span>
<span class="stat-value bad">{tempo_str}</span>
</td>
</tr>
</table>
<div class="cmd-block">
<div class="cmd-label">Para verificar no servidor</div>
<div class="cmd">
<span class="prompt">$ </span>docker exec wifi_etl_worker tail -50 /var/log/wifi-etl.log<br>
<span class="prompt">$ </span>docker logs wifi_etl_worker
</div>
</div>
<p class="note">
Este alerta não será repetido nas próximas {COOLDOWN_H}h. Se o problema
persistir, um novo aviso será enviado automaticamente.
</p>
</div>
<div class="footer">
<span class="footer-text">
Disparado automaticamente pelo watchdog ETL<br>
Heineken House · WiFi Analytics
</span>
<span class="badge">Goleiro</span>
</div>
</div>
</body>
</html>"""
def build_plain(source, last_run_at, horas, mins):
last_run_brt = last_run_at - timedelta(hours=3)
return (
f"HEINEKEN HOUSE — ETL PARADO\n"
f"{'=' * 40}\n\n"
f"Pausa na breja e nos drinks. O ETL caiu.\n\n"
f"Última fonte : {source}\n"
f"Última execução : {last_run_brt.strftime('%d/%m/%Y %H:%M BRT')}\n"
f"Tempo parado : {horas}h{mins:02d}min\n\n"
f"Para verificar:\n"
f" docker exec wifi_etl_worker tail -50 /var/log/wifi-etl.log\n"
f" docker logs wifi_etl_worker\n\n"
f"Este alerta não será repetido nas próximas {COOLDOWN_H}h.\n"
)
def send_alert(subject, source, last_run_at, horas, mins):
recipients = [e.strip() for e in WATCHDOG_RECIPIENT_EMAILS.split(',') if e.strip()]
cc = [e.strip() for e in WATCHDOG_CC_EMAILS.split(',') if e.strip()]
msg = MIMEMultipart('alternative')
msg['From'] = MAIL_FROM
msg['To'] = ', '.join(recipients)
msg['Subject'] = subject
if cc:
msg['Cc'] = ', '.join(cc)
msg.attach(MIMEText(build_plain(source, last_run_at, horas, mins), 'plain', 'utf-8'))
msg.attach(MIMEText(build_html(source, last_run_at, horas, mins), 'html', 'utf-8'))
with smtplib.SMTP(MAIL_HOST, MAIL_PORT, timeout=10) as smtp:
smtp.sendmail(MAIL_FROM, recipients + cc, msg.as_string())
log.info(f"Alerta enviado → {recipients + cc}")
def send_db_error_alert(error):
recipients = [e.strip() for e in WATCHDOG_RECIPIENT_EMAILS.split(',') if e.strip()]
cc = [e.strip() for e in WATCHDOG_CC_EMAILS.split(',') if e.strip()]
subject = "[ETL ALERTA] Falha ao conectar no banco de dados"
body = (
f"HEINEKEN HOUSE — ETL ALERTA\n\n"
f"O watchdog não conseguiu conectar no banco de dados.\n\n"
f"Erro: {error}\n\n"
f"Para verificar:\n"
f" docker logs wifi_etl_worker\n"
f" docker exec wifi_etl_worker tail -50 /var/log/wifi-etl.log\n"
)
msg = MIMEText(body, 'plain', 'utf-8')
msg['From'] = MAIL_FROM
msg['To'] = ', '.join(recipients)
msg['Subject'] = subject
if cc:
msg['Cc'] = ', '.join(cc)
with smtplib.SMTP(MAIL_HOST, MAIL_PORT, timeout=10) as smtp:
smtp.sendmail(MAIL_FROM, recipients + cc, msg.as_string())
def main():
log.info("=== WATCHDOG START ===")
try:
row = get_last_run()
except Exception as e:
log.error(f"Erro ao conectar no banco: {e}")
if not in_cooldown():
send_db_error_alert(e)
mark_cooldown()
return
if not row:
log.warning("Nenhum watermark encontrado — ETL nunca rodou?")
return
source, last_run_at = row
age = datetime.now(timezone.utc) - last_run_at
horas = int(age.total_seconds() // 3600)
mins = int((age.total_seconds() % 3600) // 60)
log.info(f"Último ETL: {source} em {last_run_at} ({horas}h{mins:02d}min atrás)")
if age > timedelta(hours=STALE_THRESHOLD_H):
if in_cooldown():
log.info("ETL parado mas em cooldown — alerta já enviado recentemente")
return
subject = f"[ETL ALERTA] Heineken House — pipeline parado há {horas}h{mins:02d}min"
send_alert(subject, source, last_run_at, horas, mins)
mark_cooldown()
else:
log.info("ETL saudável")
clear_cooldown()
log.info("=== WATCHDOG DONE ===")
if __name__ == '__main__':
main()