How to Monitor Rustler with Vigilmon
Rustler is an Elixir library that lets you write Native Implemented Functions (NIFs) in Rust. It bridges Elixir's BEAM runtime and Rust's performance guarantees — giving you CPU-intensive capabilities like image processing, cryptography, or data compression without leaving the Elixir ecosystem. Rustler compiles your Rust code as a shared library and loads it into the BEAM process at startup.
But NIFs introduce failure modes that pure-Elixir code doesn't have. A crashing NIF takes down the entire BEAM VM. A NIF that runs longer than 1 ms without yielding can block the Erlang scheduler. A compilation failure during deployment silently falls back to an error on the first call. These failures are invisible without monitoring.
This tutorial adds production observability to an Elixir application using Rustler:
- An application health check that verifies NIF loading
- HTTP uptime monitoring with Vigilmon
- NIF availability and scheduler-blocking detection
- Heartbeat monitoring for background NIF-powered workers
- Alerts on native library failures
Step 1: Add Rustler to your project
# mix.exs
defp deps do
[
{:rustler, "~> 0.34"},
{:plug_cowboy, "~> 2.0"},
{:plug, "~> 1.14"},
{:jason, "~> 1.4"},
{:req, "~> 0.4"}
]
end
Generate a NIF module:
mix rustler.new
# Module name: MyApp.Native
# Library name: my_app_native
This creates native/my_app_native/src/lib.rs. Add a simple NIF:
// native/my_app_native/src/lib.rs
use rustler::{Encoder, Env, Term};
#[rustler::nif]
fn compress(input: String) -> Vec<u8> {
// Example: fast compression in Rust
miniz_oxide::deflate::compress_to_vec(input.as_bytes(), 6)
}
#[rustler::nif]
fn decompress(input: Vec<u8>) -> Result<String, String> {
miniz_oxide::inflate::decompress_to_vec(&input)
.map_err(|e| format!("decompress error: {:?}", e))
.and_then(|bytes| String::from_utf8(bytes).map_err(|e| e.to_string()))
}
rustler::init!("Elixir.MyApp.Native", [compress, decompress]);
Define the Elixir module:
# lib/my_app/native.ex
defmodule MyApp.Native do
use Rustler, otp_app: :my_app, crate: "my_app_native"
# These stubs are replaced by the loaded NIF at runtime.
# If the NIF fails to load, calls return {:error, :nif_not_loaded}.
def compress(_input), do: :erlang.nif_error(:nif_not_loaded)
def decompress(_input), do: :erlang.nif_error(:nif_not_loaded)
end
Run mix deps.get && mix compile — Rustler compiles the Rust crate during mix compile.
Step 2: Use Rustler NIFs in your application
# lib/my_app/compression_service.ex
defmodule MyApp.CompressionService do
require Logger
def compress_data(data) when is_binary(data) do
case MyApp.Native.compress(data) do
compressed when is_list(compressed) ->
{:ok, :erlang.list_to_binary(compressed)}
{:error, reason} ->
Logger.error("NIF compress failed: #{inspect(reason)}")
{:error, reason}
end
rescue
e in ErlangError ->
Logger.error("NIF unavailable: #{inspect(e)}")
{:error, :nif_unavailable}
end
def decompress_data(compressed) when is_binary(compressed) do
case MyApp.Native.decompress(:erlang.binary_to_list(compressed)) do
{:ok, decompressed} -> {:ok, decompressed}
{:error, reason} ->
Logger.error("NIF decompress failed: #{inspect(reason)}")
{:error, reason}
end
rescue
e in ErlangError ->
{:error, :nif_unavailable}
end
end
Step 3: Add a health check with NIF status
# lib/my_app_web/plugs/health_check.ex
defmodule MyAppWeb.Plugs.HealthCheck do
import Plug.Conn
def init(opts), do: opts
def call(%Plug.Conn{request_path: "/health"} = conn, _opts) do
checks = %{
database: check_database(),
nif: check_nif(),
memory: check_memory()
}
status = if Enum.all?(checks, fn {_, v} -> v == :ok end), do: 200, else: 503
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(%{
status: (if status == 200, do: "ok", else: "degraded"),
checks: checks,
nif_info: get_nif_info()
}))
|> halt()
end
def call(conn, _opts), do: conn
defp check_database do
case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", []) do
{:ok, _} -> :ok
{:error, _} -> :error
end
rescue
_ -> :error
end
defp check_nif do
# Verify the Rustler NIF loaded and can execute
case MyApp.Native.compress("health_check_probe") do
result when is_list(result) -> :ok
_ -> :error
end
rescue
_ -> :error
end
defp check_memory do
case :erlang.memory() do
mem when is_list(mem) ->
total = Keyword.get(mem, :total, 0)
# Alert if total BEAM memory exceeds 1 GB
if total < 1_073_741_824, do: :ok, else: :error
_ -> :error
end
end
defp get_nif_info do
nif_loaded = function_exported?(MyApp.Native, :compress, 1)
%{
nif_loaded: nif_loaded,
beam_memory_mb: Float.round(:erlang.memory(:total) / (1024 * 1024), 2),
scheduler_count: :erlang.system_info(:schedulers_online)
}
rescue
_ -> %{nif_loaded: false}
end
end
Mount it before your router:
# lib/my_app_web/endpoint.ex
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
plug MyAppWeb.Plugs.HealthCheck
plug MyAppWeb.Router
end
Test it:
curl http://localhost:4000/health
# {"status":"ok","checks":{"database":"ok","nif":"ok","memory":"ok"},"nif_info":{"nif_loaded":true,"beam_memory_mb":48.3,"scheduler_count":4}}
Step 4: Track NIF call latency
NIFs that run longer than 1 ms can block BEAM schedulers. Track execution time to catch runaway calls:
# lib/my_app/nif_telemetry.ex
defmodule MyApp.NifTelemetry do
use Agent
def start_link(_), do: Agent.start_link(fn ->
%{calls: 0, errors: 0, total_us: 0, slow_calls: 0}
end, name: __MODULE__)
def track(fun) do
start = System.monotonic_time(:microsecond)
result = fun.()
elapsed_us = System.monotonic_time(:microsecond) - start
Agent.update(__MODULE__, fn s ->
s
|> Map.update!(:calls, &(&1 + 1))
|> Map.update!(:total_us, &(&1 + elapsed_us))
|> Map.update!(:slow_calls, fn c ->
# Count calls over 10 ms as "slow" (risky for schedulers)
if elapsed_us > 10_000, do: c + 1, else: c
end)
end)
result
rescue
e ->
Agent.update(__MODULE__, fn s -> Map.update!(s, :errors, &(&1 + 1)) end)
reraise e, __STACKTRACE__
end
def stats do
Agent.get(__MODULE__, fn s ->
avg_us = if s.calls > 0, do: div(s.total_us, s.calls), else: 0
Map.put(s, :avg_latency_us, avg_us)
end)
end
end
Wrap your NIF calls:
def compress_data(data) do
MyApp.NifTelemetry.track(fn -> MyApp.Native.compress(data) end)
end
Add NIF stats to the health check response:
|> send_resp(status, Jason.encode!(%{
status: (if status == 200, do: "ok", else: "degraded"),
checks: checks,
nif_info: get_nif_info(),
nif_stats: MyApp.NifTelemetry.stats()
}))
Step 5: Set up HTTP monitoring in Vigilmon
Point Vigilmon at your health endpoint:
- Sign up at vigilmon.online
- Click New Monitor → HTTP
- Enter
https://yourdomain.com/health - Set check interval: 1 minute (paid) or 5 minutes (free)
- Under Advanced, add a keyword check for
"nif":"ok"to confirm NIF availability - Save
Why external monitoring matters for NIF applications:
A failed NIF load is silent at the application level — the BEAM starts, Phoenix serves requests, and everything looks fine until a call hits the failing NIF stub and returns {:error, :nif_not_loaded}. Vigilmon's keyword check on "nif":"ok" catches this immediately after deployment and alerts before users are affected.
Vigilmon's response time tracking also detects scheduler-blocking NIFs: a NIF blocking all schedulers will cause every other request to queue, producing a sharp response-time spike even though the health endpoint eventually responds.
Step 6: Heartbeat monitoring for NIF-powered background workers
# lib/my_app/workers/compression_worker.ex
defmodule MyApp.Workers.CompressionWorker do
use GenServer
require Logger
@interval :timer.minutes(5)
def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
@impl true
def init(state) do
schedule_tick()
{:ok, state}
end
@impl true
def handle_info(:process, state) do
pending = MyApp.Repo.all(MyApp.PendingCompressionJob)
Enum.each(pending, fn job ->
case MyApp.CompressionService.compress_data(job.data) do
{:ok, compressed} ->
MyApp.Repo.update!(MyApp.PendingCompressionJob.changeset(job, %{
compressed: compressed,
status: "done"
}))
{:error, reason} ->
Logger.error("Compression job #{job.id} failed: #{inspect(reason)}")
end
end)
ping_heartbeat()
schedule_tick()
{:noreply, state}
end
defp schedule_tick, do: Process.send_after(self(), :process, @interval)
defp ping_heartbeat do
url = Application.get_env(:my_app, :vigilmon)[:compression_worker_heartbeat_url]
if url do
case Req.get(url, receive_timeout: 5_000) do
{:ok, _} -> :ok
{:error, reason} -> Logger.warning("Heartbeat ping failed: #{inspect(reason)}")
end
end
end
end
In Vigilmon:
- New Monitor → Heartbeat
- Set interval to 10 minutes (5-minute job with 5-minute grace)
- Copy the ping URL → set as
VIGILMON_COMPRESSION_WORKER_HEARTBEAT_URLenv var - Configure as
Application.get_env(:my_app, :vigilmon)[:compression_worker_heartbeat_url]
Step 7: Alerts via Slack
In Vigilmon, go to Notifications → New Channel → Slack and paste your incoming webhook URL.
Enable the channel on all monitors. Rustler NIF incidents often look like:
- HTTP health check degraded (NIF failed to load after deployment)
- Response time spike (NIF blocking all schedulers)
- Compression worker heartbeat missed (worker crashed after NIF error)
Configure Vigilmon to send all three alert types to your on-call channel so NIF incidents are caught before they cascade.
Step 8: Status page
- Status Pages → New Status Page in Vigilmon
- Add your HTTP health monitor and all heartbeat monitors
- Share the URL in your
READMEand runbook
README badge:

What you've built
| What | How |
|------|-----|
| Health check plug | HealthCheck plug with NIF probe call |
| NIF load verification | function_exported?/3 + live test call |
| NIF latency tracking | NifTelemetry Agent with slow-call counter |
| Scheduler-blocking detection | Vigilmon response time history |
| HTTP uptime monitoring | Vigilmon HTTP monitor → /health with keyword check |
| Slack downtime alerts | Vigilmon Slack notification channel |
| Worker heartbeat | Heartbeat ping from CompressionWorker GenServer |
| Status page | Vigilmon public status page |
Rustler makes Elixir fast. Vigilmon makes sure it stays running.
Next steps
- Add dirty NIF scheduling for long-running Rust computations (
rustler::schedule::DirtyCpu) to prevent scheduler blocking, and monitor for the absence of latency spikes in Vigilmon's response time history - Use Vigilmon's response time SLA alerts to detect when NIF-powered endpoints exceed your performance budget
- Set up a staging monitor in Vigilmon that checks NIF availability immediately after each deployment before traffic is cut over
- Track per-NIF error rates in your observability stack to identify which Rust functions are failing most frequently in production
Get started free at vigilmon.online.