Skip to content

Commit 4e4e68f

Browse files
authored
feat(observability): slow-query log + Prometheus metrics (#324)
* feat(observability): slow-query log + Prometheus metrics (#293) - Add SQLAlchemy before/after_cursor_execute event listeners in database.py - Log slow_query warning (structlog) when query duration exceeds 500 ms - Expose http_db_query_seconds Histogram (every query) via Prometheus - Expose db_slow_queries_total Counter (slow queries only) via Prometheus - Threshold configurable via SLOW_QUERY_THRESHOLD_MS env var (default 500) - Add test_slow_query.py with 25 tests covering all instrumentation paths - Update test_db_pool.py to patch _register_slow_query_listener where create_engine is mocked, fixing pre-existing test compatibility Closes #293 * fix(observability): guard Prometheus metrics against duplicate registration on reload test_alembic_env.py calls importlib.reload(database) to test URL construction. Each reload tried to re-register DB_QUERY_DURATION and DB_SLOW_QUERY_COUNTER in the global Prometheus registry, raising: ValueError: Duplicated timeseries in CollectorRegistry Fix: add _get_or_create_histogram / _get_or_create_counter helpers that look up an existing collector by name in REGISTRY._names_to_collectors and return it instead of constructing a new one. The metric objects are functionally identical across reloads so all instrumentation continues to work correctly.
1 parent 64fd60c commit 4e4e68f

3 files changed

Lines changed: 628 additions & 8 deletions

File tree

quantara/web_app/db/database.py

Lines changed: 148 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,147 @@
44
Reads PostgreSQL connection parameters from environment variables and
55
exposes the SQLAlchemy engine, session factory, and declarative base
66
for 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

923
import os
24+
import time
1025
from typing import Generator
1126

1227
from 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
1431
from 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+
1688
engine = None
1789
SessionLocal = None
1890
Base = 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+
20148
def 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+
34163
def _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+
53185
def 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

60198
def 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+
69213
def get_database() -> Generator[Session, None, None]:
70214
"""
71215
FastAPI dependency that yields a database session and ensures cleanup.

quantara/web_app/tests/test_db_pool.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
The actual pool behaviour is exercised in the integration suite against a
1010
real PostgreSQL container. This file focuses narrowly on configuration by
1111
patching ``create_engine`` so no database is required.
12+
13+
Note: ``_register_slow_query_listener`` is also patched in every test that
14+
mocks ``create_engine``. The listener registration calls ``event.listen``
15+
on the engine object, which requires a real SQLAlchemy engine; using a
16+
MagicMock would raise ``InvalidRequestError``. Since pool-configuration
17+
tests care only about the kwargs forwarded to ``create_engine``, we simply
18+
no-op the listener step here.
1219
"""
1320

1421
from unittest.mock import patch
@@ -41,7 +48,8 @@ def test_database_init_db_uses_env_pool_settings(monkeypatch):
4148

4249
from web_app.db import database
4350

44-
with patch("web_app.db.database.create_engine") as create_engine:
51+
with patch("web_app.db.database.create_engine") as create_engine, \
52+
patch("web_app.db.database._register_slow_query_listener"):
4553
database.init_db()
4654
assert create_engine.call_count == 1
4755
kwargs = create_engine.call_args.kwargs
@@ -58,7 +66,8 @@ def test_database_init_db_falls_back_to_defaults(monkeypatch):
5866

5967
from web_app.db import database
6068

61-
with patch("web_app.db.database.create_engine") as create_engine:
69+
with patch("web_app.db.database.create_engine") as create_engine, \
70+
patch("web_app.db.database._register_slow_query_listener"):
6271
database.init_db()
6372
kwargs = create_engine.call_args.kwargs
6473
assert kwargs["pool_size"] == DEFAULT_POOL_SIZE
@@ -85,7 +94,8 @@ def test_db_connector_routes_through_init_engine(monkeypatch):
8594

8695
from web_app.db.crud.base import DBConnector
8796

88-
with patch("web_app.db.database.create_engine") as create_engine:
97+
with patch("web_app.db.database.create_engine") as create_engine, \
98+
patch("web_app.db.database._register_slow_query_listener"):
8999
DBConnector(db_url="postgresql://user:pwd@host:5432/dbname")
90100
assert create_engine.call_count == 1
91101
kwargs = create_engine.call_args.kwargs
@@ -102,7 +112,8 @@ def test_db_connector_falls_back_to_defaults(monkeypatch):
102112

103113
from web_app.db.crud.base import DBConnector
104114

105-
with patch("web_app.db.database.create_engine") as create_engine:
115+
with patch("web_app.db.database.create_engine") as create_engine, \
116+
patch("web_app.db.database._register_slow_query_listener"):
106117
DBConnector(db_url="postgresql://user:pwd@host:5432/dbname")
107118
kwargs = create_engine.call_args.kwargs
108119
assert kwargs["pool_size"] == DEFAULT_POOL_SIZE

0 commit comments

Comments
 (0)