88 lines
2.6 KiB
Python
88 lines
2.6 KiB
Python
"""
|
|
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,
|
|
}
|