How to Monitor NimblePool with Vigilmon
NimblePool is a lean, efficient resource pool library for Elixir. Unlike heavyweight alternatives, it provides a simple GenServer-based pool that manages a fixed number of workers — HTTP connections, database handles, external process ports, or any resource that is expensive to create but cheap to reuse. Phoenix applications use it under the hood for Mint HTTP connections; you can use it directly wherever you need a bounded pool of reusable resources.
Pool failures are subtle and cumulative. A pool that saturates under load causes callers to wait or time out. A pool that leaks checked-out resources shrinks over time until new requests queue indefinitely. A worker that crashes and does not reinitialize correctly drains pool capacity silently. None of these failures raise an exception immediately — they manifest as slow responses, connection timeouts, or stalled queues that are hard to trace back to pool exhaustion.
Vigilmon monitors give you early warning before pool saturation becomes a user-visible incident.
Why Monitor NimblePool?
Pool health issues degrade gracefully until they don't:
- Pool exhaustion — all workers are checked out; new requests queue indefinitely or time out, causing cascading slowdowns across your application
- Worker leak — a caller crashes after checkout without returning the worker, gradually draining the pool below its configured size
- Slow worker initialization — a
handle_checkout/4callback that calls a slow external service causes pool startup to block the supervisor - Worker crash loop — a
handle_checkin/4error terminates a worker that is immediately restarted, burning CPU and causing transient failures for callers - Unbalanced usage — a subset of callers monopolizes the pool, starving other consumers and creating head-of-line blocking
These failures share a common signature: pool queue depth rises, checkout latency increases, and throughput drops — all before any error log appears.
Key Metrics to Track
| Metric | What it tells you |
|--------|-------------------|
| Pool queue depth | Number of callers waiting for a worker to become available |
| Checkout wait time | How long callers block before receiving a worker |
| Worker utilization | Percentage of pool capacity currently checked out |
| Worker initialization time | Latency of handle_checkout/4 for fresh workers |
| Checkout timeout rate | Frequency of :timeout results from NimblePool.checkout!/3 |
| Worker restart rate | How often workers crash and are reinitialized |
Step 1: Add NimblePool to Your Project
# mix.exs
defp deps do
[
{:nimble_pool, "~> 1.1"},
{:telemetry, "~> 1.2"},
# rest of your deps
]
end
mix deps.get
Define a pool worker. This example manages a pool of Mint HTTP connections:
# lib/my_app/http_pool.ex
defmodule MyApp.HttpPool do
@behaviour NimblePool
require Logger
@host "api.example.com"
@port 443
@connect_timeout 5_000
def start_link(opts) do
NimblePool.start_link(
worker: {__MODULE__, opts},
pool_size: Keyword.get(opts, :size, 10),
name: __MODULE__
)
end
def checkout!(fun, timeout \\ 5_000) do
start = System.monotonic_time()
result =
NimblePool.checkout!(__MODULE__, :checkout, fn _from, conn ->
try do
{fun.(conn), conn}
rescue
e -> {{:error, e}, :discard}
end
end, timeout)
duration = System.monotonic_time() - start
:telemetry.execute(
[:my_app, :http_pool, :checkout],
%{duration: duration},
%{result: elem(result, 0) |> result_tag()}
)
result
rescue
e in NimblePool.CheckoutError ->
:telemetry.execute([:my_app, :http_pool, :timeout], %{count: 1}, %{})
reraise e, __STACKTRACE__
end
# NimblePool callbacks
@impl NimblePool
def init_worker(_pool_state) do
start = System.monotonic_time()
{:ok, conn} = Mint.HTTP.connect(:https, @host, @port, timeout: @connect_timeout)
duration = System.monotonic_time() - start
:telemetry.execute([:my_app, :http_pool, :worker_init], %{duration: duration}, %{})
{:ok, conn, _pool_state = nil}
end
@impl NimblePool
def handle_checkout(:checkout, _from, conn, pool_state) do
{:ok, conn, conn, pool_state}
end
@impl NimblePool
def handle_checkin(conn, _from, _old_conn, pool_state) do
if Mint.HTTP.open?(conn) do
{:ok, conn, pool_state}
else
:telemetry.execute([:my_app, :http_pool, :worker_discard], %{count: 1}, %{reason: :closed})
{:remove, :closed}
end
end
@impl NimblePool
def terminate_worker(reason, _conn, pool_state) do
:telemetry.execute([:my_app, :http_pool, :worker_terminate], %{count: 1}, %{reason: reason})
{:ok, pool_state}
end
defp result_tag({:ok, _}), do: :ok
defp result_tag({:error, _}), do: :error
defp result_tag(_), do: :unknown
end
Step 2: Attach Telemetry Handlers for Pool Events
# lib/my_app/pool_telemetry.ex
defmodule MyApp.PoolTelemetry do
require Logger
def attach do
:telemetry.attach_many(
"my-app-pool-handler",
[
[:my_app, :http_pool, :checkout],
[:my_app, :http_pool, :timeout],
[:my_app, :http_pool, :worker_init],
[:my_app, :http_pool, :worker_discard],
[:my_app, :http_pool, :worker_terminate]
],
&handle_event/4,
nil
)
end
defp handle_event([:my_app, :http_pool, :checkout], %{duration: d}, %{result: r}, _) do
ms = System.convert_time_unit(d, :native, :millisecond)
if ms > 1_000 do
Logger.warning("HttpPool: slow checkout #{ms}ms (result=#{r})")
end
end
defp handle_event([:my_app, :http_pool, :timeout], _, _, _) do
Logger.error("HttpPool: checkout timed out — pool may be exhausted")
end
defp handle_event([:my_app, :http_pool, :worker_discard], _, %{reason: reason}, _) do
Logger.warning("HttpPool: worker discarded (reason=#{reason})")
end
defp handle_event(_, _, _, _), do: :ok
end
Attach handlers in your Application start:
# lib/my_app/application.ex
def start(_type, _args) do
MyApp.PoolTelemetry.attach()
children = [
MyApp.Repo,
{MyApp.HttpPool, size: 10},
MyAppWeb.Endpoint
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
Step 3: Add Pool Health Metrics to Telemetry.Metrics
# lib/my_app/telemetry.ex
def metrics do
[
# Pool checkout latency distribution
distribution("my_app.http_pool.checkout.duration",
unit: {:native, :millisecond},
reporter_options: [buckets: [10, 50, 100, 500, 1000, 5000]],
tags: [:result]
),
# Timeout counter
counter("my_app.http_pool.timeout.count"),
# Worker lifecycle
counter("my_app.http_pool.worker_init.count"),
distribution("my_app.http_pool.worker_init.duration",
unit: {:native, :millisecond}
),
counter("my_app.http_pool.worker_discard.count", tags: [:reason]),
counter("my_app.http_pool.worker_terminate.count", tags: [:reason])
]
end
Step 4: Add a Pool Health Endpoint
# lib/my_app_web/plugs/pool_health.ex
defmodule MyAppWeb.Plugs.PoolHealth do
import Plug.Conn
def init(opts), do: opts
def call(%Plug.Conn{request_path: "/health/pool"} = conn, _opts) do
checks = %{
http_pool_alive: check_pool(MyApp.HttpPool),
http_pool_utilization: pool_utilization(MyApp.HttpPool)
}
status =
cond do
checks.http_pool_alive == :down -> 503
checks.http_pool_utilization >= 0.9 -> 503
true -> 200
end
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(%{
status: if(status == 200, do: "ok", else: "degraded"),
checks: checks
}))
|> halt()
end
def call(conn, _opts), do: conn
defp check_pool(pool) do
case Process.whereis(pool) do
nil -> :down
pid -> if Process.alive?(pid), do: :ok, else: :down
end
end
defp pool_utilization(pool) do
case :sys.get_state(pool) do
%{workers: workers, available: available} ->
Float.round(1.0 - length(available) / length(workers), 2)
_ ->
0.0
end
rescue
_ -> 0.0
end
end
Step 5: Create Monitors in Vigilmon
HTTP monitor for pool health endpoint:
- Sign in at vigilmon.online
- Click New Monitor → HTTP
- Set URL to
https://your-app.example.com/health/pool - Set interval to 60 seconds
- Alert condition: status must be
200
Heartbeat monitor for pool checkout success:
Add a periodic ping from your application when pool checkouts succeed above a threshold:
# lib/my_app/pool_heartbeat.ex
defmodule MyApp.PoolHeartbeat do
use GenServer
@heartbeat_url System.get_env("VIGILMON_POOL_HEARTBEAT_URL")
@interval :timer.seconds(30)
@timeout_threshold 0.05 # alert if >5% checkouts timeout
def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
def init(state), do: {:ok, state, {:continue, :start}}
def handle_continue(:start, state) do
schedule()
{:noreply, state}
end
def handle_info(:ping, state) do
if pool_healthy?() do
if @heartbeat_url do
:httpc.request(:get, {String.to_charlist(@heartbeat_url), []}, [], [])
end
end
schedule()
{:noreply, state}
end
defp pool_healthy? do
case Process.whereis(MyApp.HttpPool) do
nil -> false
pid -> Process.alive?(pid)
end
end
defp schedule, do: Process.send_after(self(), :ping, @interval)
end
Create a Heartbeat monitor in Vigilmon with a 2-minute expected interval to alert when the pool stops reporting healthy.
Step 6: Alerting
In Vigilmon, configure Notifications → New Channel:
Slack for pool exhaustion:
🔴 ALERT: NimblePool saturation — MyApp HttpPool
Monitor: /health/pool returning 503
Utilization: 90%+ (all workers checked out)
Action: Increase pool_size or investigate slow callers holding workers
PagerDuty for complete pool failure:
Configure a high-priority alert when the pool process itself goes down — this indicates a supervisor failure, not just load saturation.
Common NimblePool Issues and Fixes
Pool exhaustion under load:
# Increase pool size — the default is often too small for production load
{MyApp.HttpPool, size: 25}
# Or add a checkout timeout to fail fast rather than queue indefinitely
NimblePool.checkout!(__MODULE__, :checkout, fn _, conn -> {use(conn), conn} end, 2_000)
Worker leak from caller crash:
NimblePool handles caller crashes automatically — if a caller process dies while holding a checked-out worker, the pool receives a :DOWN message and calls handle_checkin/4 with :error. Ensure your callback handles this:
@impl NimblePool
def handle_checkin(:error, _from, _conn, pool_state) do
# Caller crashed — discard the connection since we don't know its state
{:remove, :caller_crash}
end
Worker initialization blocking startup:
# Use lazy initialization to avoid blocking the supervisor
{MyApp.HttpPool, size: 10, lazy: true}
What You've Built
| What | How |
|------|-----|
| Instrumented pool | Telemetry events on checkout, timeout, worker init/discard/terminate |
| Pool health endpoint | HTTP check verifying pool process and utilization |
| Telemetry.Metrics integration | Checkout latency distribution, timeout counter, worker lifecycle |
| Pool liveness heartbeat | GenServer pinging Vigilmon every 30 seconds when healthy |
| Infrastructure alerting | Vigilmon HTTP monitor on /health/pool |
| Saturation alerting | 503 on >90% utilization triggers Slack notification |
NimblePool keeps your resource usage bounded and efficient. Vigilmon keeps you informed when the bounds are being tested.
Next Steps
- Add per-caller tracking to identify which modules hold workers longest
- Set up Grafana panels for checkout latency p95/p99 to catch slow external services
- Consider a secondary pool with a larger size as a burst buffer for high-traffic periods
- Use Vigilmon's response time history to correlate pool health with traffic spikes
Get started free at vigilmon.online.