Monitoring MongoDB Driver Pool Events
This guide is part of MongoDB Driver Connection Pools. MongoDB drivers do not expose a pool metrics object to poll. They emit events — connection created, checked out, checked in, checkout failed, pool cleared — and the application decides what to do with them. Services that never subscribe are left guessing during incidents, reading application timeouts without knowing whether the pool was full, refilling after a failover, or failing to connect at all:
2026-09-16T14:22:47Z ERROR orders-api operation failed: Timed out while checking out a connection
2026-09-16T14:22:47Z ERROR orders-api operation failed: Timed out while checking out a connection
# ...and nothing about why
The objective is a small, permanent subscription that converts the pool’s events into a handful of metrics, a dashboard that answers the three questions an incident poses, and alerts that fire on the signals that precede user-visible failure.
Rapid incident diagnosis
Before adding instrumentation, a temporary subscription answers the immediate question. Every driver supports it; in Node.js it is four lines:
for (const ev of ["connectionPoolCreated", "connectionPoolCleared", "connectionCreated",
"connectionClosed", "connectionCheckOutStarted", "connectionCheckOutFailed",
"connectionCheckedOut", "connectionCheckedIn"]) {
client.on(ev, (e) => console.log(ev, e.address, e.reason ?? "", e.connectionId ?? ""));
}
In Python, pymongo.monitoring.register(ConnectionPoolListener()) with a subclass that logs each callback does the same; in Java, MongoClientSettings.builder().addConnectionPoolListener(...).
Read the sequence, not the individual lines.
| Pattern in the event stream | Meaning |
|---|---|
checkOutStarted ≫ checkedOut, failures with reason timeout, no connectionCreated |
Pool at maxPoolSize: saturation |
checkOutStarted high, connectionCreated climbing, failures with reason timeout |
Establishment is the bottleneck: maxConnecting or slow handshakes |
Failures with reason connectionError |
TLS, authentication or network failure during establishment |
connectionPoolCleared followed by many connectionCreated |
Server marked unknown: failover or heartbeat failure |
connectionClosed with reason idle in a steady stream |
Churn from maxIdleTimeMS or a network idle timeout |
Note the address on every event. Pools are per server, so a saturated primary pool and idle secondary pools are the normal shape of a write-heavy incident. Aggregating events without the address hides that.
Check the reason field on closures too. connectionClosed carries reason — stale (the pool was cleared), idle (maxIdleTimeMS expired), error, or poolClosed. A stream of stale closures means repeated pool clears, which is a topology problem rather than a pool one.
Mathematical sizing or parameter formula
Events are frequent, so the metric design matters more than the subscription. Each checked-out operation emits at least three events; at 4,000 operations per second that is 12,000 events per second per process, which must be aggregated in memory rather than exported individually.
Counters and gauges to keep:
in_use(address) = checkedOut − checkedIn (gauge, per address)
utilisation(address) = in_use ÷ maxPoolSize (derived)
checkout_failures = count of checkOutFailed by reason (counter)
pool_clears = count of connectionPoolCleared (counter)
connections_created = count of connectionCreated (counter)
connections_closed = count of connectionClosed by reason (counter)
checkout_duration = checkedOut.time − checkOutStarted.time (histogram)
Cardinality. Labels should be address and, where the event provides it, reason. With three servers and four closure reasons, one process produces about 20 series for the pool — trivial. Adding per-operation or per-collection labels would multiply that by hundreds and is not worth it; per-operation latency belongs to command metrics, not pool metrics.
Histogram buckets for checkout duration should resolve the healthy case and the timeout:
0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5
Most checkouts complete in well under a millisecond because a connection is already idle; the interesting tail is between 10 ms and the configured bound.
Sampling the duration. If event volume is a concern, sample checkout durations — for example every tenth operation — while keeping the counters exact. Counters drive alerts; the histogram only needs enough samples for a percentile.
Exact remediation & configuration
Node.js with prom-client:
import { Counter, Gauge, Histogram } from "prom-client";
const inUse = new Gauge({ name: "mongo_pool_in_use", help: "Connections checked out", labelNames: ["address"] });
const failures = new Counter({ name: "mongo_pool_checkout_failures_total", help: "Failed checkouts", labelNames: ["address", "reason"] });
const clears = new Counter({ name: "mongo_pool_cleared_total", help: "Pool clears", labelNames: ["address"] });
const created = new Counter({ name: "mongo_pool_connections_created_total", help: "Connections created", labelNames: ["address"] });
const closed = new Counter({ name: "mongo_pool_connections_closed_total", help: "Connections closed", labelNames: ["address", "reason"] });
const checkout = new Histogram({
name: "mongo_pool_checkout_seconds", help: "Time to check out a connection", labelNames: ["address"],
buckets: [0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5],
});
const started = new Map();
client.on("connectionCheckOutStarted", (ev) => started.set(ev.requestId ?? ev.address, process.hrtime.bigint()));
client.on("connectionCheckedOut", (ev) => {
inUse.labels(ev.address).inc();
const t0 = started.get(ev.requestId ?? ev.address);
if (t0) { checkout.labels(ev.address).observe(Number(process.hrtime.bigint() - t0) / 1e9); started.delete(ev.requestId ?? ev.address); }
});
client.on("connectionCheckedIn", (ev) => inUse.labels(ev.address).dec());
client.on("connectionCheckOutFailed", (ev) => failures.labels(ev.address, ev.reason ?? "unknown").inc());
client.on("connectionPoolCleared", (ev) => clears.labels(ev.address).inc());
client.on("connectionCreated", (ev) => created.labels(ev.address).inc());
client.on("connectionClosed", (ev) => closed.labels(ev.address, ev.reason ?? "unknown").inc());
Python with PyMongo:
from pymongo import monitoring
from prometheus_client import Counter, Gauge
IN_USE = Gauge("mongo_pool_in_use", "Connections checked out", ["address"])
FAILURES = Counter("mongo_pool_checkout_failures_total", "Failed checkouts", ["address", "reason"])
CLEARS = Counter("mongo_pool_cleared_total", "Pool clears", ["address"])
class PoolMetrics(monitoring.ConnectionPoolListener):
def connection_checked_out(self, event):
IN_USE.labels(f"{event.address[0]}:{event.address[1]}").inc()
def connection_checked_in(self, event):
IN_USE.labels(f"{event.address[0]}:{event.address[1]}").dec()
def connection_check_out_failed(self, event):
FAILURES.labels(f"{event.address[0]}:{event.address[1]}", str(event.reason)).inc()
def pool_cleared(self, event):
CLEARS.labels(f"{event.address[0]}:{event.address[1]}").inc()
# the remaining callbacks must exist even if they do nothing
def pool_created(self, event): pass
def pool_ready(self, event): pass
def pool_closed(self, event): pass
def connection_created(self, event): pass
def connection_ready(self, event): pass
def connection_closed(self, event): pass
def connection_check_out_started(self, event): pass
monitoring.register(PoolMetrics())
Java with Micrometer:
class PoolMetrics implements ConnectionPoolListener {
private final MeterRegistry registry;
private final ConcurrentMap<String, AtomicInteger> inUse = new ConcurrentHashMap<>();
PoolMetrics(MeterRegistry registry) { this.registry = registry; }
private AtomicInteger gauge(ServerId id) {
return inUse.computeIfAbsent(id.getAddress().toString(), a ->
registry.gauge("mongo.pool.in.use", Tags.of("address", a), new AtomicInteger()));
}
@Override public void connectionCheckedOut(ConnectionCheckedOutEvent e) { gauge(e.getConnectionId().getServerId()).incrementAndGet(); }
@Override public void connectionCheckedIn(ConnectionCheckedInEvent e) { gauge(e.getConnectionId().getServerId()).decrementAndGet(); }
@Override public void connectionCheckOutFailed(ConnectionCheckOutFailedEvent e) {
registry.counter("mongo.pool.checkout.failures", "address", e.getServerId().getAddress().toString(),
"reason", e.getReason().toString()).increment();
}
@Override public void connectionPoolCleared(ConnectionPoolClearedEvent e) {
registry.counter("mongo.pool.cleared", "address", e.getServerId().getAddress().toString()).increment();
}
}
Alert on the three signals that matter:
groups:
- name: mongodb-pool
rules:
- alert: MongoPoolCheckoutFailures
expr: rate(mongo_pool_checkout_failures_total[5m]) > 0
for: 2m
labels: { severity: critical }
annotations:
summary: "{{ $labels.address }}: checkout failures ({{ $labels.reason }})"
- alert: MongoPoolUtilisationHigh
expr: max by (address) (mongo_pool_in_use) / 11 > 0.8
for: 10m
labels: { severity: warning }
- alert: MongoPoolClearedRepeatedly
expr: increase(mongo_pool_cleared_total[15m]) > 3
labels: { severity: warning }
annotations:
summary: "{{ $labels.address }}: pool cleared repeatedly — topology instability"
Dashboard layout
Four panels answer the questions an incident actually poses, and they should be readable in the order below.
| Panel | Query | Question it answers |
|---|---|---|
| In use versus maximum, by address | mongo_pool_in_use with a threshold line at maxPoolSize |
Is any pool full, and which server? |
| Checkout failures by reason | rate(mongo_pool_checkout_failures_total[5m]) stacked by reason |
Are operations failing, and why? |
| Checkout duration p50/p99 | histogram_quantile(...) over mongo_pool_checkout_seconds_bucket |
How long are operations waiting? |
| Connection churn | rate(mongo_pool_connections_created_total[5m]) and ..._closed_total by reason |
Are connections being recycled or cleared? |
Two annotations make the panels self-explanatory during an incident: deployments, and pool clears. A clear is a discrete event with a precise meaning — the driver stopped trusting a server — and marking it on the timeline usually explains a spike in the other three panels without further investigation.
Keep the panels split by address rather than summed. A summed view of a replica set hides the normal asymmetry between the primary’s pool and the secondaries’, and it is exactly that asymmetry that identifies a write-path problem.
Finally, record the maxPoolSize in use as a constant series or a dashboard variable, so the utilisation panel does not silently become wrong when someone resizes the pool:
# a recording rule keeps the threshold in one place
mongo_pool_max_size{service="orders-api"} = 11
Validation & verification
Every event type is being received. Force each one in staging and confirm the metric moves: normal traffic (checked out and in), a pool capped at one connection (checkout failures with reason timeout), a wrong password (reason connectionError), and a stepdown (connectionPoolCleared).
The in-use gauge does not drift. Because it is derived from two events, a missed decrement leaks upward forever. After a load test, with traffic stopped, the gauge must return to zero:
max_over_time(mongo_pool_in_use[5m]) == 0
A gauge that does not return to zero means some checkout path is not emitting a check-in — usually an operation that errored — and the subscription should be corrected before the metric is trusted for alerting.
Durations agree with reality. During a deliberately saturated test, the p99 of mongo_pool_checkout_seconds must approach the configured wait bound and not exceed it. Exceeding it means the bound is not in effect.
Alerts fire and resolve. Run the capped-pool test long enough for MongoPoolCheckoutFailures to fire, then restore capacity and confirm it resolves. An alert that never resolves is usually a counter rate over too long a window.
Frequently Asked Questions
Do pool events slow the driver down?
Can I get the same data without subscribing?
serverStatus().connections shows totals from the server’s point of view, which is useful for fleet-level headroom but cannot attribute connections to a service or explain a checkout failure. The client-side events are the only place the reason exists.Do these events exist in every driver?
Should I export these through OpenTelemetry instead?
db.client.connection.* instruments so MongoDB pools appear alongside SQL pools on one dashboard. The naming and Collector pipeline are covered in OpenTelemetry Connection Pool Tracing.Related
- MongoDB Driver Connection Pools — the parent guide to the pool model these events describe.
- Diagnosing MongoDB Connection Storms After Failover — reading pool clears and creation spikes during an election.
- Tuning MongoDB maxPoolSize and waitQueueTimeoutMS — the settings these metrics validate.
- Building a Grafana Dashboard for Connection Pool Metrics — the same panel conventions for SQL pools.