import logging from os import getpid from socket import gethostname from threading import Event, Thread from fastapi import FastAPI from app.core.config import get_settings from app.core.constants import ActorValue from app.core.database import SessionLocal from app.modules.observability.constants import HeartbeatComponent from app.modules.observability.service import ObservabilityService logger = logging.getLogger(__name__) def attach_api_heartbeat(app: FastAPI) -> None: """Record API process liveness without coupling it to request traffic.""" interval_seconds = max(1, get_settings().heartbeat_interval_seconds) instance_id = f"{gethostname()}:{getpid()}" @app.on_event("startup") def start_api_heartbeat() -> None: current_thread = getattr(app.state, "api_heartbeat_thread", None) if current_thread is not None and current_thread.is_alive(): return stop_event = Event() _record_api_heartbeat(instance_id) thread = Thread( target=_api_heartbeat_loop, args=(stop_event, instance_id, interval_seconds), name="api-heartbeat", daemon=True, ) app.state.api_heartbeat_stop_event = stop_event app.state.api_heartbeat_thread = thread app.state.api_heartbeat_instance_id = instance_id thread.start() @app.on_event("shutdown") def stop_api_heartbeat() -> None: stop_event = getattr(app.state, "api_heartbeat_stop_event", None) thread = getattr(app.state, "api_heartbeat_thread", None) if stop_event is not None: stop_event.set() if thread is not None: thread.join(timeout=1) def _api_heartbeat_loop( stop_event: Event, instance_id: str, interval_seconds: int, ) -> None: while not stop_event.wait(max(1, interval_seconds)): _record_api_heartbeat(instance_id) def _record_api_heartbeat(instance_id: str) -> None: db = SessionLocal() try: ObservabilityService(db).record_heartbeat( component=HeartbeatComponent.API, instance_id=instance_id, actor=ActorValue.API, ) except Exception: db.rollback() logger.exception("Failed to record API process heartbeat") finally: db.close()