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.

MongoDB pool event sequence for one operation An operation emits checkOutStarted, then either connectionCheckedOut on success, or connectionCheckOutFailed with a reason. Between them the pool may emit connectionCreated if it needed a new connection, bounded by maxConnecting. checkOutStarted operation begins idle connection? below maxPoolSize? connectionCreated bounded by maxConnecting checkedOut operation runs checkOutFailed reason: timeout or connectionError checkedIn on completion
The event stream tells you which stage failed. Checkout started but not completed, with or without connections being created, separates saturation from slow establishment.

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.

Distribution of connection checkout durations A histogram showing the vast majority of checkouts completing under one millisecond, a small number between ten and one hundred milliseconds, and a tail reaching the two second timeout. Checkout duration distribution, one hour <1 ms 1–5 5–10 10–50 50–100 0.1–1 s 1–2 s the tail is where saturation lives; the bulk tells you the pool is healthy
Almost every checkout is immediate. The histogram matters only for the tail, which is why its buckets should be dense below a millisecond and extend to the configured timeout.

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
MongoDB pool dashboard layout Four panels: in-use connections against the maximum per address, checkout failures by reason, checkout duration percentiles, and connection churn by reason, arranged in the order an incident is investigated. 1 · In use vs maxPoolSize per server address 2 · Checkout failures stacked by reason 3 · Checkout duration p50 and p99 4 · Connection churn created and closed by reason Read them in order annotate deploys and pool clears; split by address, never summed
Four panels in this order take an on-call engineer from "operations are failing" to a cause without leaving the dashboard.

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?
The events are emitted regardless; the cost is in the handlers. Keep them to counter increments and map operations — no logging, no I/O, no JSON serialisation — and the overhead is negligible. Logging every event in production is the mistake to avoid.
Can I get the same data without subscribing?
Partly. 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?
Yes — connection pool monitoring is part of the driver specification, so Node.js, Python, Java, Go, C#, Ruby, Rust and PHP all provide an equivalent listener interface, with the same event names and semantics.
Should I export these through OpenTelemetry instead?
If the organisation standardises on OpenTelemetry, map the same events onto 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.