44Reads PostgreSQL connection parameters from environment variables and
55exposes the SQLAlchemy engine, session factory, and declarative base
66for use throughout the web application.
7+
8+ Observability
9+ ─────────────
10+ A SQLAlchemy ``before_cursor_execute`` / ``after_cursor_execute`` event pair
11+ is registered on every engine produced by this module. Any statement that
12+ takes longer than SLOW_QUERY_THRESHOLD_MS (default 500 ms) is logged at
13+ WARNING level with the ``slow_query`` event key and the actual duration.
14+
15+ The Prometheus counter ``http_db_query_seconds`` is incremented for **every**
16+ finished query, regardless of duration, so scrape targets can compute a
17+ request-rate baseline. A separate ``db_slow_queries_total`` counter tracks
18+ only the slow queries, making it easy to build an alert rule such as::
19+
20+ rate(db_slow_queries_total[5m]) > 0
721"""
822
923import os
24+ import time
1025from typing import Generator
1126
1227from dotenv import load_dotenv
13- from sqlalchemy import create_engine
28+ from prometheus_client import Counter , Histogram
29+ from sqlalchemy import create_engine , event
30+ from sqlalchemy .engine import Connection
1431from sqlalchemy .orm import Session , declarative_base , sessionmaker
1532
33+ from web_app .utils .logger import get_logger
34+
35+ logger = get_logger (__name__ )
36+
37+ # ── Prometheus metrics ────────────────────────────────────────────────────────
38+ # Guard against duplicate registration when the module is reloaded (e.g. in
39+ # tests that call ``importlib.reload``). prometheus_client raises ValueError
40+ # on duplicate metric names, so we reuse any collector that is already
41+ # registered under the same name instead of creating a new one.
42+
43+ from prometheus_client import REGISTRY as _PROM_REGISTRY # noqa: E402
44+
45+
46+ def _get_or_create_histogram (name , documentation , labelnames , buckets ):
47+ """Return an existing Histogram from the registry or create a new one."""
48+ existing = _PROM_REGISTRY ._names_to_collectors .get (name )
49+ if existing is not None :
50+ return existing
51+ return Histogram (name , documentation , labelnames , buckets = buckets )
52+
53+
54+ def _get_or_create_counter (name , documentation ):
55+ """Return an existing Counter from the registry or create a new one."""
56+ # prometheus_client stores counters under '<name>_total' in newer versions.
57+ existing = (
58+ _PROM_REGISTRY ._names_to_collectors .get (name )
59+ or _PROM_REGISTRY ._names_to_collectors .get (f"{ name } _total" )
60+ )
61+ if existing is not None :
62+ return existing
63+ return Counter (name , documentation )
64+
65+
66+ #: Total seconds spent executing database queries (histogram).
67+ #: Label ``query_type`` is always "sql" — extend if you need finer granularity.
68+ DB_QUERY_DURATION = _get_or_create_histogram (
69+ "http_db_query_seconds" ,
70+ "Duration of individual database queries in seconds" ,
71+ ["query_type" ],
72+ buckets = (0.005 , 0.01 , 0.025 , 0.05 , 0.1 , 0.25 , 0.5 , 1.0 , 2.5 , 5.0 , 10.0 ),
73+ )
74+
75+ #: Incremented every time a query exceeds SLOW_QUERY_THRESHOLD_MS.
76+ DB_SLOW_QUERY_COUNTER = _get_or_create_counter (
77+ "db_slow_queries_total" ,
78+ "Number of database queries that exceeded the slow-query threshold" ,
79+ )
80+
81+ # Threshold in milliseconds. Override via SLOW_QUERY_THRESHOLD_MS env var.
82+ SLOW_QUERY_THRESHOLD_MS : float = float (
83+ os .environ .get ("SLOW_QUERY_THRESHOLD_MS" , "500" )
84+ )
85+
86+ # ── Module-level engine / session state ──────────────────────────────────────
87+
1688engine = None
1789SessionLocal = None
1890Base = declarative_base ()
1991
92+
93+ # ── Slow-query instrumentation ────────────────────────────────────────────────
94+
95+
96+ def _before_cursor_execute ( # pylint: disable=too-many-arguments,unused-argument
97+ conn : Connection ,
98+ cursor ,
99+ statement : str ,
100+ parameters ,
101+ context ,
102+ executemany : bool ,
103+ ) -> None :
104+ """Record the wall-clock start time on the connection's info dict."""
105+ conn .info .setdefault ("_query_start_time" , []).append (time .perf_counter ())
106+
107+
108+ def _after_cursor_execute ( # pylint: disable=too-many-arguments,unused-argument
109+ conn : Connection ,
110+ cursor ,
111+ statement : str ,
112+ parameters ,
113+ context ,
114+ executemany : bool ,
115+ ) -> None :
116+ """Compute elapsed time, update Prometheus metrics, and log slow queries."""
117+ start_times : list = conn .info .get ("_query_start_time" , [])
118+ if not start_times :
119+ return
120+
121+ start = start_times .pop ()
122+ elapsed_seconds : float = time .perf_counter () - start
123+ elapsed_ms : float = elapsed_seconds * 1000.0
124+
125+ # Always record to the histogram.
126+ DB_QUERY_DURATION .labels (query_type = "sql" ).observe (elapsed_seconds )
127+
128+ # Log and count slow queries.
129+ if elapsed_ms > SLOW_QUERY_THRESHOLD_MS :
130+ DB_SLOW_QUERY_COUNTER .inc ()
131+ logger .warning (
132+ "slow_query" ,
133+ duration_ms = round (elapsed_ms , 3 ),
134+ threshold_ms = SLOW_QUERY_THRESHOLD_MS ,
135+ statement = statement [:500 ], # truncate to avoid huge log lines
136+ )
137+
138+
139+ def _register_slow_query_listener (eng ) -> None :
140+ """Attach the before/after cursor-execute listeners to *eng*."""
141+ event .listen (eng , "before_cursor_execute" , _before_cursor_execute )
142+ event .listen (eng , "after_cursor_execute" , _after_cursor_execute )
143+
144+
145+ # ── URL / pool helpers ────────────────────────────────────────────────────────
146+
147+
20148def get_database_url () -> str :
21149 """Construct and return the database URL from environment variables."""
22150 load_dotenv (override = False )
@@ -31,6 +159,7 @@ def get_database_url() -> str:
31159 f"postgresql://{ DB_USER } :{ DB_PASSWORD } @{ DB_SERVER } :{ DB_PORT } /{ DB_NAME } "
32160 )
33161
162+
34163def _engine_pool_kwargs () -> dict :
35164 """Return the standard pool-related kwargs for every SQLAlchemy engine.
36165
@@ -50,22 +179,37 @@ def _engine_pool_kwargs() -> dict:
50179 }
51180
52181
182+ # ── Engine factories ──────────────────────────────────────────────────────────
183+
184+
53185def init_engine (db_url : str = None ):
54- """Construct a SQLAlchemy engine that uses the project's pool policy."""
186+ """Construct a SQLAlchemy engine that uses the project's pool policy.
187+
188+ The slow-query event listeners are automatically registered on the
189+ returned engine.
190+ """
55191 if db_url is None :
56192 db_url = get_database_url ()
57- return create_engine (db_url , ** _engine_pool_kwargs ())
193+ eng = create_engine (db_url , ** _engine_pool_kwargs ())
194+ _register_slow_query_listener (eng )
195+ return eng
58196
59197
60198def init_db () -> None :
61- """Initialize the module-level database connection and session factory."""
199+ """Initialize the module-level database connection and session factory.
200+
201+ The slow-query event listeners are automatically registered on the
202+ engine produced here.
203+ """
62204 global engine , SessionLocal
63205 if engine is not None :
64206 return
65207
66208 engine = create_engine (get_database_url (), ** _engine_pool_kwargs ())
209+ _register_slow_query_listener (engine )
67210 SessionLocal = sessionmaker (autocommit = False , autoflush = False , bind = engine )
68211
212+
69213def get_database () -> Generator [Session , None , None ]:
70214 """
71215 FastAPI dependency that yields a database session and ensures cleanup.
0 commit comments