""" Health check endpoint — no authentication required. Used by uptime monitors (UptimeRobot, etc.) to verify the app and subsystems. """ import logging import time from datetime import datetime from flask import Blueprint, jsonify from sqlalchemy import func, text from app.extensions import db health_bp = Blueprint('health', __name__) log = logging.getLogger('app.health') @health_bp.route('/health') def health_check(): db_result = _check_db() cron_result = _check_crons() status = 'ok' if db_result['status'] != 'ok': status = 'degraded' if any(v.get('overdue') for v in cron_result.values() if isinstance(v, dict)): status = 'degraded' return jsonify({ 'status': status, 'timestamp': datetime.utcnow().isoformat() + 'Z', 'db': db_result, 'crons': cron_result, }), 200 if status == 'ok' else 503 def _check_db(): t0 = time.monotonic() try: db.session.execute(text('SELECT 1')) ms = round((time.monotonic() - t0) * 1000, 1) return {'status': 'ok', 'latency_ms': ms} except Exception as e: log.error('[health] DB check failed: %s', e) return {'status': 'error', 'error': str(e)} def _check_crons(): from app.models.fx_rate import FxRate from app.models.ai_insight import AiInsight from app.models.net_worth_snapshot import NetWorthSnapshot from app.models.investment import Investment return { # expected daily — overdue after 48 h 'fetch_fx_rate': _stat( db.session.query(func.max(FxRate.fetched_at)).scalar(), max_hours=48, ), # expected daily — overdue after 48 h 'daily_ai_insight': _stat( db.session.query(func.max(AiInsight.created_at)) .filter(AiInsight.insight_type == 'daily_summary').scalar(), max_hours=48, ), # expected monthly — overdue after 35 days 'daily_snapshot': _stat( db.session.query(func.max(NetWorthSnapshot.created_at)).scalar(), max_hours=35 * 24, ), # expected weekdays — overdue after 4 days 'fetch_prices': _stat( db.session.query(func.max(Investment.last_price_update)).scalar(), max_hours=4 * 24, ), } def _stat(last_run_dt, max_hours): if last_run_dt is None: return {'last_run': None, 'age_hours': None, 'overdue': False} age = round((datetime.utcnow() - last_run_dt).total_seconds() / 3600, 1) return { 'last_run': last_run_dt.isoformat() + 'Z', 'age_hours': age, 'overdue': age > max_hours, }