Building a Grafana Dashboard for Connection Pool Metrics
This guide is part of Prometheus and Grafana Pool Metrics. Collecting pool metrics is the easy half. The hard half is arranging them so that someone paged at three in the morning reaches the right conclusion quickly, rather than looking at nine graphs and guessing.
A pool dashboard has exactly one job: distinguish between the three states a pool can be in. Not busy, busy and coping, and saturated. Every panel should serve that distinction, and panels that do not should be removed.
The four panels that matter
| Panel | Question it answers | Why it is here |
|---|---|---|
| Utilisation | How full is the pool? | Context — but never an alert on its own |
| Pending waiters | Are callers queuing? | The unambiguous saturation signal |
| Acquisition latency | What is the queue costing? | Converts saturation into user-visible impact |
| Borrow hold time | Why are connections not coming back? | Separates a small pool from a slow database |
The ordering is deliberate: it is the order an investigation actually runs in. Utilisation tells you the pool is full; pending waiters tells you that is a problem; latency tells you how bad; hold time tells you what to change.
The single most important design decision is that utilisation must not be the alert. A pool at 100% utilisation with no waiters is a correctly sized pool doing its job efficiently. Paging on it trains people to ignore the dashboard, which is worse than having no dashboard.
The PromQL
These use Micrometer’s HikariCP metric names. The Go and Node equivalents follow the same shapes with different series names.
Panel 1 — utilisation. A ratio, so it is comparable across pools of different sizes:
sum by (pool) (hikaricp_connections_active)
/
sum by (pool) (hikaricp_connections_max)
Set the panel unit to percentunit, the range to 0–1, and thresholds at 0.8 amber and 0.95 red. Those colours are informational, not alerting.
Panel 2 — pending waiters. The saturation verdict:
sum by (pool) (hikaricp_connections_pending)
Use a bar or a stepped line rather than a smoothed one, because the value is a small integer and smoothing hides the transition from 0 to 1 — which is the entire signal.
Panel 3 — acquisition latency. A summary or histogram, depending on your Micrometer configuration:
histogram_quantile(0.99,
sum by (pool, le) (rate(hikaricp_connections_acquire_seconds_bucket[5m])))
Plot p50 alongside p99. A p50 near zero with a high p99 means a small fraction of requests are queuing — usually a periodic batch competing with request traffic. Both rising together means the whole workload is queuing.
Panel 4 — borrow hold time. The diagnostic panel:
histogram_quantile(0.99,
sum by (pool, le) (rate(hikaricp_connections_usage_seconds_bucket[5m])))
This is the panel that decides what to change, and it is the one most dashboards omit. Read it against panel 2: waiters rising while hold time is flat means the pool is too small for the arrival rate. Waiters rising because hold time rose means the database slowed down, and enlarging the pool sends more concurrent work at an already-struggling database.
Wiring the metrics up
Micrometer publishes HikariCP metrics automatically in Spring Boot once the actuator and a registry are present:
management:
endpoints.web.exposure.include: prometheus,health
metrics.tags.application: ${spring.application.name}
metrics.distribution:
percentiles-histogram:
hikaricp.connections.acquire: true
hikaricp.connections.usage: true
slo:
hikaricp.connections.acquire: 1ms,5ms,10ms,50ms,100ms,500ms,1s,5s
percentiles-histogram: true is required for histogram_quantile to work — without it Micrometer publishes client-side percentiles that cannot be aggregated across instances, and a p99 averaged across pods is not a p99 of anything.
For Go, export DBStats on a collection interval:
prometheus.MustRegister(collectors.NewDBStatsCollector(db, "orders"))
That gives go_sql_stats_connections_waited_for_total and go_sql_stats_connections_blocked_seconds_total, which are counters rather than gauges. The equivalent of panel 2 is therefore a rate:
sum by (db_name) (rate(go_sql_stats_connections_waited_for_total[5m]))
For Node, expose pool.waitingCount as a gauge on your existing metrics endpoint. The counters are described in tuning node-postgres pool timeout settings.
Labels and variables
Every series needs at least three labels: application, pool, and instance. Without pool you cannot tell a primary pool from a replica pool; without instance you cannot tell whether saturation is fleet-wide or concentrated on one pod.
Add Grafana variables so one dashboard serves every service:
$application = label_values(hikaricp_connections_max, application)
$pool = label_values(hikaricp_connections_max{application="$application"}, pool)
Then use sum by (instance) inside a panel repeated over $pool, so a service with a primary and a replica pool gets one set of panels each rather than a single graph that adds them together. Summing a primary pool and a replica pool produces a number that describes nothing.
One instance saturating while others are fine is a distinct and common failure — usually an uneven load balancer, or one pod with a leaked connection. That pattern is only visible if instance is preserved rather than aggregated away, which is why the panels above use sum by (pool) for the fleet view and repeat by instance below.
Alert rules to attach
Two rules cover the pool. Both are deliberately about waiters, not utilisation:
groups:
- name: connection-pool
rules:
- alert: PoolSaturated
expr: max by (application, pool) (hikaricp_connections_pending) > 0
for: 5m
labels: { severity: warning }
annotations:
summary: "{{ $labels.application }}/{{ $labels.pool }} has queued callers"
- alert: PoolAcquisitionSlow
expr: |
histogram_quantile(0.99,
sum by (application, pool, le)
(rate(hikaricp_connections_acquire_seconds_bucket[5m]))) > 1
for: 5m
labels: { severity: critical }
annotations:
summary: "{{ $labels.application }}/{{ $labels.pool }} p99 acquisition above 1s"
The for: 5m matters. Pending waiters spike briefly under any burst, and a rule without a duration will page on normal traffic. Five minutes of sustained queueing is a real condition; five seconds is a burst the pool absorbed.
max by rather than avg by is what makes the single-saturated-instance case alertable. The threshold selection and severity split are developed further in Detecting Connection Pool Saturation.
Common failure patterns and remediation
| Symptom | Underlying cause | Remediation |
|---|---|---|
histogram_quantile returns no data |
percentiles-histogram not enabled |
Enable it in Micrometer; client-side percentiles cannot be aggregated |
| p99 across pods looks wrong | Averaging pre-computed percentiles | Use histogram buckets and compute the quantile in PromQL |
| Dashboard shows one flat line for everything | Pools summed without a pool label |
Add the label; repeat panels by $pool |
| Alerts fire constantly | Alerting on utilisation rather than waiters | Alert on pending waiters with a for duration |
| Saturation invisible in the fleet view | Aggregation hid a single instance | Alert on max by and add a per-instance panel |
| Waiters panel looks empty | Smoothed line hiding integer transitions | Use a stepped line or bar |
| Cannot tell whether to grow the pool | No borrow hold time panel | Add it; it is what distinguishes the two causes |
| Metrics missing after a deploy | Pool renamed, so series changed | Pin poolName in configuration rather than letting it default |
What to add on a second row
Four panels fit on one screen and answer the primary question. A collapsed second row is the right home for everything else:
- Connection creation rate. A high rate means connections are churning — usually
minimumIdlebelowmaximumPoolSize, or an age cap set aggressively. - Timeout rate. How often acquisition actually failed, as opposed to merely being slow.
- Total connections against the database ceiling. The fleet-wide sum compared against
max_connections, which is a capacity-planning signal rather than an incident one. - Leak detection warning rate. If leak detection is enabled, the count of warnings without a matching return — see configuring the HikariCP leak detection threshold.
- Database active backends. From the exporter on the database side, to confirm whether the database is the constraint.
That last one is worth linking rather than embedding: a dashboard that mixes application-side and database-side metrics without clearly separating them leads people to compare numbers that are not comparable. Keep them adjacent and labelled.
Operational boundary
A dashboard describes the pool. It cannot tell you whether the database has capacity for a larger pool, whether a query plan regressed, or whether a connection was leaked in a code path that has since been deployed away. When the four panels point at the database rather than the pool, the next stop is the server side — active backends, wait events, and lock contention, as covered in interpreting pg_stat_activity wait events for pool issues.
Resist the urge to add panels. A dashboard with twenty graphs is not more informative than one with four; it is less, because nobody reads twenty graphs at three in the morning.
Frequently Asked Questions
Should I alert on pool utilisation?
Why does my p99 look wrong across instances?
What is the single most useful panel?
Why include borrow hold time?
How do I handle multiple pools per service?
pool label, repeat the panels by a $pool variable, and never sum a primary pool with a replica pool.What for duration should alerts use?
Should the database metrics be on the same dashboard?
Related
- Prometheus and Grafana Pool Metrics — the parent guide on exporting the metrics.
- Detecting Connection Pool Saturation — the thresholds behind these alerts.
- PgBouncer Metrics Monitoring — adding the proxy layer to the same dashboard.
- Interpreting pg_stat_activity Wait Events for Pool Issues — where to go when the panels point at the database.
- Configuring the HikariCP Leak Detection Threshold — the source of the leak-warning series.