Skip to content

Commit eeefc94

Browse files
committed
Merge PR #1020: feat(observability): add db query duration histogram and pool exhaustion alert
2 parents ed1eed7 + 765aa38 commit eeefc94

6 files changed

Lines changed: 209 additions & 2 deletions

File tree

performance/config/alerts.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,17 @@ groups:
111111
description: "Database p99 query time is {{ $value }}s, exceeding 100ms critical threshold"
112112
runbook_url: "https://docs.predictiq.com/runbooks/critical-database-queries"
113113

114+
- alert: DBPoolExhaustion
115+
expr: rate(db_pool_exhaustion_total[5m]) > 0.0167
116+
for: 1m
117+
labels:
118+
severity: critical
119+
component: database
120+
annotations:
121+
summary: "Database connection pool exhaustion detected"
122+
description: "Pool exhaustion rate is {{ $value }} per second, exceeding 1/minute threshold"
123+
runbook_url: "https://docs.predictiq.com/runbooks/db-pool-exhaustion"
124+
114125
- alert: HighDBConnectionPoolUtilization
115126
expr: db_connections_active / db_connections_max > 0.8
116127
for: 5m

performance/config/grafana-dashboard.json

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,57 @@
842842
"notifications": []
843843
}
844844
}
845+
},
846+
{
847+
"id": 10,
848+
"title": "DB Pool Utilisation (% of max_connections)",
849+
"type": "graph",
850+
"gridPos": { "x": 12, "y": 24, "w": 12, "h": 8 },
851+
"targets": [
852+
{
853+
"expr": "db_pool_connections_active{pool=\"api\"} / (db_pool_connections_active{pool=\"api\"} + db_pool_connections_idle{pool=\"api\"}) * 100",
854+
"legendFormat": "Pool Utilisation %",
855+
"refId": "A"
856+
}
857+
],
858+
"yaxes": [
859+
{
860+
"format": "percent",
861+
"label": "Utilisation"
862+
}
863+
],
864+
"alert": {
865+
"name": "High DB Pool Utilisation",
866+
"conditions": [
867+
{
868+
"evaluator": {
869+
"params": [80],
870+
"type": "gt"
871+
},
872+
"operator": {
873+
"type": "and"
874+
},
875+
"query": {
876+
"params": ["A", "5m", "now"]
877+
},
878+
"reducer": {
879+
"params": [],
880+
"type": "avg"
881+
},
882+
"type": "query"
883+
}
884+
],
885+
"executionErrorState": "alerting",
886+
"frequency": "1m",
887+
"handler": 1,
888+
"message": "DB pool utilisation exceeded 80%",
889+
"noDataState": "no_data",
890+
"notifications": []
891+
}
892+
}
893+
]
894+
}
895+
}
845896
]
846897
}
847898
}

performance/config/grafana-slo-dashboard.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,10 +242,52 @@
242242
"type": "text",
243243
"content": "## Error Budget Policy\n\n- **100% remaining**: Normal operations\n- **50% remaining**: Review recent changes\n- **25% remaining**: Freeze non-critical deployments\n- **10% remaining**: Freeze all deployments\n- **0% remaining**: Emergency - rollback and incident response\n\n### Burn Rate Thresholds\n\n- **Fast Burn (14.4x)**: Exhausts budget in 2 days → Critical alert\n- **Slow Burn (6.0x)**: Exhausts budget in 5 days → Warning alert"
244244
}
245+
],
246+
},
247+
{
248+
"id": 11,
249+
"title": "Database Query Latency P50/P95/P99",
250+
"type": "graph",
251+
"gridPos": { "x": 0, "y": 0, "w": 12, "h": 8 },
252+
"targets": [
253+
{
254+
"expr": "histogram_quantile(0.50, sum(rate(db_query_duration_seconds_bucket[5m])) by (le)) * 1000",
255+
"legendFormat": "P50 Latency",
256+
"refId": "A"
257+
},
258+
{
259+
"expr": "histogram_quantile(0.95, sum(rate(db_query_duration_seconds_bucket[5m])) by (le)) * 1000",
260+
"legendFormat": "P95 Latency",
261+
"refId": "B"
262+
},
263+
{
264+
"expr": "histogram_quantile(0.99, sum(rate(db_query_duration_seconds_bucket[5m])) by (le)) * 1000",
265+
"legendFormat": "P99 Latency",
266+
"refId": "C"
267+
},
268+
{
269+
"expr": "500",
270+
"legendFormat": "P95 Alert Threshold",
271+
"refId": "D"
272+
}
273+
],
274+
"yaxes": [
275+
{
276+
"label": "Latency (ms)",
277+
"format": "ms"
278+
}
279+
]
280+
}
245281
],
246282
"time": {
247283
"from": "now-7d",
248284
"to": "now"
249285
}
250286
}
251287
}
288+
"time": {
289+
"from": "now-7d",
290+
"to": "now"
291+
}
292+
}
293+
}

performance/config/prometheus-slo-rules.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ groups:
3939
sum(rate(db_query_duration_seconds_bucket[5m])) by (le)
4040
) <= 0.05
4141
42+
# Database Pool Exhaustion Rate SLO
43+
- record: slo:db_pool_exhaustion:burn_rate
44+
expr: |
45+
rate(db_pool_exhaustion_total[5m])
46+
4247
# Cache Availability SLO
4348
- record: slo:cache_availability:success_rate
4449
expr: |
@@ -171,3 +176,30 @@ groups:
171176
summary: "Cache availability SLO violation"
172177
description: "Redis cache availability below 99.95% target. Current: {{ $value }}%"
173178
runbook_url: "https://docs.predictiq.com/runbooks/cache-availability-slo-violation"
179+
180+
# Database Query P95 Latency Alert
181+
- alert: DBSlowQueryP95
182+
expr: |
183+
histogram_quantile(0.95,
184+
sum(rate(db_query_duration_seconds_bucket[5m])) by (le)
185+
) > 0.5
186+
for: 5m
187+
labels:
188+
severity: warning
189+
slo: db_query_latency
190+
annotations:
191+
summary: "Database P95 query latency exceeds 500ms"
192+
description: "Database P95 query latency is {{ $value }}s, exceeding 500ms threshold for 5 minutes"
193+
runbook_url: "https://docs.predictiq.com/runbooks/slow-database-queries"
194+
195+
# Database Connection Pool Exhaustion Alert
196+
- alert: DBPoolExhaustion
197+
expr: rate(db_pool_exhaustion_total[5m]) > 0.0167
198+
for: 1m
199+
labels:
200+
severity: critical
201+
slo: db_pool_availability
202+
annotations:
203+
summary: "Database connection pool exhaustion rate exceeds 1/min"
204+
description: "Pool exhaustion rate is {{ $value | humanize }} per second (threshold: 0.0167/s = 1/min)"
205+
runbook_url: "https://docs.predictiq.com/runbooks/db-pool-exhaustion"

services/api/src/db.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,14 +185,27 @@ impl Database {
185185
}
186186

187187
/// Run `fut` with the configured query timeout.
188+
/// On success, records the query duration in the `db_query_duration_seconds` histogram.
188189
/// On timeout, increments the `db_timeouts` metric and logs a warning.
190+
/// On pool exhaustion, increments the `db_pool_exhaustion_total` counter.
189191
async fn with_timeout<F, T>(&self, operation: &str, fut: F) -> Result<T, DbError>
190192
where
191193
F: std::future::Future<Output = Result<T, sqlx::Error>>,
192194
{
195+
let start = std::time::Instant::now();
193196
match tokio::time::timeout(self.query_timeout, fut).await {
194-
Ok(Ok(v)) => Ok(v),
195-
Ok(Err(e)) => Err(DbError::Other(anyhow::Error::from(e))),
197+
Ok(Ok(v)) => {
198+
self.metrics
199+
.observe_db_query_duration(operation, start.elapsed());
200+
Ok(v)
201+
}
202+
Ok(Err(e)) => {
203+
if matches!(&e, sqlx::Error::PoolTimedOut) {
204+
self.metrics.observe_db_pool_exhaustion("api");
205+
return Err(DbError::PoolExhausted);
206+
}
207+
Err(DbError::Other(anyhow::Error::from(e)))
208+
}
196209
Err(_elapsed) => {
197210
self.metrics.observe_db_timeout(operation);
198211
tracing::warn!(operation, timeout_secs = ?self.query_timeout, "db query timed out");

services/api/src/metrics.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ pub struct Metrics {
3232
request_latency: HistogramVec,
3333
rpc_errors: IntCounterVec,
3434
rpc_fallbacks: IntCounterVec,
35+
db_query_duration: HistogramVec,
3536
db_timeouts: IntCounterVec,
37+
db_pool_exhaustion: IntCounterVec,
3638
ledger_gaps: IntCounterVec,
3739
email_dlq_size: IntGauge,
3840
email_queue_depth: IntGauge,
@@ -93,12 +95,33 @@ impl Metrics {
9395
)
9496
.context("rpc_fallbacks metric")?;
9597

98+
let db_query_duration = HistogramVec::new(
99+
prometheus::HistogramOpts::new(
100+
"db_query_duration_seconds",
101+
"Database query duration in seconds by query name",
102+
)
103+
.buckets(vec![
104+
0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0,
105+
]),
106+
&["query_name"],
107+
)
108+
.context("db_query_duration metric")?;
109+
96110
let db_timeouts = IntCounterVec::new(
97111
prometheus::Opts::new("db_timeouts_total", "DB queries that exceeded the timeout, by operation"),
98112
&["operation"],
99113
)
100114
.context("db_timeouts metric")?;
101115

116+
let db_pool_exhaustion = IntCounterVec::new(
117+
prometheus::Opts::new(
118+
"db_pool_exhaustion_total",
119+
"Number of times the connection pool was exhausted, by pool name",
120+
),
121+
&["pool"],
122+
)
123+
.context("db_pool_exhaustion metric")?;
124+
102125
let ledger_gaps = IntCounterVec::new(
103126
prometheus::Opts::new(
104127
"blockchain_ledger_gaps_total",
@@ -183,7 +206,9 @@ impl Metrics {
183206
registry.register(Box::new(request_latency.clone()))?;
184207
registry.register(Box::new(rpc_errors.clone()))?;
185208
registry.register(Box::new(rpc_fallbacks.clone()))?;
209+
registry.register(Box::new(db_query_duration.clone()))?;
186210
registry.register(Box::new(db_timeouts.clone()))?;
211+
registry.register(Box::new(db_pool_exhaustion.clone()))?;
187212
registry.register(Box::new(ledger_gaps.clone()))?;
188213
registry.register(Box::new(email_dlq_size.clone()))?;
189214
registry.register(Box::new(email_queue_depth.clone()))?;
@@ -202,7 +227,9 @@ impl Metrics {
202227
request_latency,
203228
rpc_errors,
204229
rpc_fallbacks,
230+
db_query_duration,
205231
db_timeouts,
232+
db_pool_exhaustion,
206233
ledger_gaps,
207234
email_dlq_size,
208235
email_queue_depth,
@@ -253,11 +280,23 @@ impl Metrics {
253280
self.rpc_fallbacks.with_label_values(&[&labels[0]]).inc();
254281
}
255282

283+
pub fn observe_db_query_duration(&self, query_name: &str, duration: Duration) {
284+
self.db_query_duration
285+
.with_label_values(&[query_name])
286+
.observe(duration.as_secs_f64());
287+
}
288+
256289
pub fn observe_db_timeout(&self, operation: &str) {
257290
let labels = normalize_label_values(&[operation]);
258291
self.db_timeouts.with_label_values(&[&labels[0]]).inc();
259292
}
260293

294+
pub fn observe_db_pool_exhaustion(&self, pool: &str) {
295+
self.db_pool_exhaustion
296+
.with_label_values(&[pool])
297+
.inc();
298+
}
299+
261300
/// Record a ledger-gap event on `network`, incrementing the counter by `gap_size` ledgers.
262301
pub fn observe_ledger_gap(&self, network: &str, gap_size: u32) {
263302
if gap_size > 0 {
@@ -387,6 +426,25 @@ impl Metrics {
387426
mod tests {
388427
use super::*;
389428

429+
#[test]
430+
fn observe_db_query_duration_records_histogram() {
431+
let metrics = Metrics::new().unwrap();
432+
metrics.observe_db_query_duration("test_query", Duration::from_millis(100));
433+
let output = metrics.render().unwrap();
434+
assert!(output.contains("db_query_duration_seconds"));
435+
assert!(output.contains("query_name=\"test_query\""));
436+
}
437+
438+
#[test]
439+
fn observe_db_pool_exhaustion_increments_counter() {
440+
let metrics = Metrics::new().unwrap();
441+
metrics.observe_db_pool_exhaustion("api");
442+
let output = metrics.render().unwrap();
443+
assert!(output.contains("db_pool_exhaustion_total"));
444+
assert!(output.contains("pool=\"api\""));
445+
assert!(output.contains("1"));
446+
}
447+
390448
// ── normalize_label ────────────────────────────────────────────────────────
391449

392450
#[test]

0 commit comments

Comments
 (0)