tutorial

How to Monitor Rustler with Vigilmon

Learn how to add health checks, NIF observability, and alerts to Elixir applications using the Rustler library with Vigilmon.

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:

  1. Sign up at vigilmon.online
  2. Click New Monitor → HTTP
  3. Enter https://yourdomain.com/health
  4. Set check interval: 1 minute (paid) or 5 minutes (free)
  5. Under Advanced, add a keyword check for "nif":"ok" to confirm NIF availability
  6. 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:

  1. New Monitor → Heartbeat
  2. Set interval to 10 minutes (5-minute job with 5-minute grace)
  3. Copy the ping URL → set as VIGILMON_COMPRESSION_WORKER_HEARTBEAT_URL env var
  4. 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

  1. Status Pages → New Status Page in Vigilmon
  2. Add your HTTP health monitor and all heartbeat monitors
  3. Share the URL in your README and runbook

README badge:

![Uptime](https://vigilmon.online/badge/your-monitor-slug)

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.

Monitor your app with Vigilmon

Free plan — 5 monitors, no credit card required. Up and running in 60 seconds.

Start free →