tutorial

How to Monitor Zigler with Vigilmon

Monitor Elixir Zigler NIF applications — track native function latency, scheduler blocking, memory safety violations, and BEAM health when running Zig-compiled extensions with external uptime and heartbeat monitoring.

How to Monitor Zigler with Vigilmon

Zigler lets you write Elixir Native Implemented Functions (NIFs) in Zig — a systems language that gives you C-level performance with compile-time memory safety guarantees. When you need to run compute-intensive work directly on the BEAM scheduler thread (or in a dirty scheduler), Zigler is the idiomatic choice: no c_src/ boilerplate, no manual NIF registration macros, just Zig functions annotated with beam types.

The risk is proportional to the power. A NIF that blocks the scheduler longer than 1 ms degrades every process on the node. A Zig function that panics or violates memory safety brings down the entire BEAM VM — not just the calling process. External monitoring is the only way to know when a NIF regression slips past your test suite and into production.

This tutorial covers:

  • A health check endpoint that exposes NIF execution statistics
  • HTTP uptime monitoring with Vigilmon
  • Heartbeat monitoring for dirty-scheduler NIF jobs
  • Alerts when NIF latency or scheduler blockage exceeds safe thresholds

Why Monitor Zigler?

Zig NIFs fail in ways that don't appear in BEAM crash reports:

| Failure mode | Symptom without monitoring | |---|---| | NIF blocks scheduler > 1 ms | BEAM responsiveness degrades; no crash | | Zig panic in non-dirty NIF | Entire VM crashes; no Erlang stack trace | | Memory allocation failure in Zig | enomem returned; callers receive opaque error | | Dirty NIF thread pool exhaustion | Callers queue silently; latency spikes | | Zig segfault via unsafe pointer cast | VM segfault; OS-level core dump only | | NIF version mismatch after hot reload | badarg errors on every call |


Step 1: Telemetry wrapper around Zigler NIFs

Zigler-generated NIFs are called like regular Elixir functions. Wrap them with :telemetry to track invocation counts and latency:

# lib/my_app/nif_wrapper.ex
defmodule MyApp.NifWrapper do
  require Logger

  @doc """
  Wraps a Zigler NIF call with telemetry events for latency and error tracking.
  """
  def call(module, function, args) do
    start_time = System.monotonic_time()

    :telemetry.execute([:my_app, :nif, :start], %{}, %{module: module, function: function})

    try do
      result = apply(module, function, args)

      duration = System.monotonic_time() - start_time

      :telemetry.execute(
        [:my_app, :nif, :stop],
        %{duration: duration},
        %{module: module, function: function}
      )

      result
    rescue
      error ->
        :telemetry.execute(
          [:my_app, :nif, :exception],
          %{},
          %{module: module, function: function, error: error}
        )
        reraise error, __STACKTRACE__
    end
  end
end

Attach a handler that accumulates stats in a GenServer:

# lib/my_app/nif_monitor.ex
defmodule MyApp.NifMonitor do
  use GenServer

  @slow_threshold_ms 5

  def start_link(_), do: GenServer.start_link(__MODULE__, initial_state(), name: __MODULE__)

  def stats, do: GenServer.call(__MODULE__, :stats)

  defp initial_state do
    %{total_calls: 0, errors: 0, slow_calls: 0, total_duration_ms: 0}
  end

  @impl true
  def init(state) do
    :ok = :telemetry.attach_many(
      "nif-monitor",
      [[:my_app, :nif, :stop], [:my_app, :nif, :exception]],
      &__MODULE__.handle_event/4,
      nil
    )
    {:ok, state}
  end

  def handle_event([:my_app, :nif, :stop], %{duration: duration}, _meta, _config) do
    duration_ms = System.convert_time_unit(duration, :native, :millisecond)
    GenServer.cast(__MODULE__, {:call_completed, duration_ms})
  end

  def handle_event([:my_app, :nif, :exception], _measurements, _meta, _config) do
    GenServer.cast(__MODULE__, :call_failed)
  end

  @impl true
  def handle_cast({:call_completed, duration_ms}, state) do
    slow = if duration_ms > @slow_threshold_ms, do: 1, else: 0
    {:noreply, %{state |
      total_calls: state.total_calls + 1,
      slow_calls: state.slow_calls + slow,
      total_duration_ms: state.total_duration_ms + duration_ms
    }}
  end

  def handle_cast(:call_failed, state) do
    {:noreply, %{state | errors: state.errors + 1}}
  end

  @impl true
  def handle_call(:stats, _from, state) do
    avg_ms = if state.total_calls > 0,
      do: Float.round(state.total_duration_ms / state.total_calls, 2),
      else: 0.0
    {:reply, Map.put(state, :avg_duration_ms, avg_ms), state}
  end
end

Add to your supervision tree:

# lib/my_app/application.ex
children = [
  MyApp.Repo,
  MyAppWeb.Endpoint,
  MyApp.NifMonitor,
]

Step 2: Health check endpoint

# 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
    nif_stats = MyApp.NifMonitor.stats()
    db_ok = check_database()

    nif_ok =
      nif_stats.errors == 0 and
      nif_stats.slow_calls < 10 and
      nif_stats.avg_duration_ms < 50.0

    status = if db_ok and nif_ok, do: 200, else: 503

    body = Jason.encode!(%{
      status: if(status == 200, do: "ok", else: "degraded"),
      checks: %{
        database: if(db_ok, do: "ok", else: "error"),
        nif_health: if(nif_ok, do: "ok", else: "degraded"),
        nif_stats: nif_stats
      }
    })

    conn
    |> put_resp_content_type("application/json")
    |> send_resp(status, body)
    |> halt()
  end

  def call(conn, _opts), do: conn

  defp check_database do
    case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", []) do
      {:ok, _} -> true
      _ -> false
    end
  end
end

Wire it in before the router:

# lib/my_app_web/endpoint.ex
plug MyAppWeb.Plugs.HealthCheck
plug MyAppWeb.Router

Test it:

curl http://localhost:4000/health
# {"status":"ok","checks":{"database":"ok","nif_health":"ok","nif_stats":{...}}}

Step 3: Set up HTTP monitoring in Vigilmon

  1. Log in at vigilmon.online
  2. Click New Monitor → HTTP
  3. Enter https://yourdomain.com/health
  4. Set interval: 1 minute
  5. Add a keyword check for "degraded" — set it to alert when the keyword IS present
  6. Set threshold: 2 consecutive failures before alerting

The keyword check catches cases where a NIF regression causes nif_health to read "degraded" even if the HTTP status is still 200 (for example, if you soft-degrade instead of returning 503).


Step 4: Heartbeat monitoring for long-running dirty NIFs

Zigler NIFs running on dirty schedulers are the safe way to do work over 1 ms. But a dirty NIF that silently hangs blocks a dirty scheduler thread and queues subsequent callers. Add a heartbeat to your wrapper:

# lib/my_app/heavy_computation.ex
defmodule MyApp.HeavyComputation do
  use Zigler

  @heartbeat_url System.get_env("VIGILMON_NIF_HEARTBEAT_URL")

  # Zigler NIF declaration (compiled from Zig source)
  ~Z"""
  pub fn compute_hash(env: beam.env, data: []const u8) beam.term {
    // Zig implementation here
    return beam.make(env, @as(u64, 0), .{});
  }
  """

  def run_with_heartbeat(data) do
    result = MyApp.NifWrapper.call(__MODULE__, :compute_hash, [data])
    ping_heartbeat()
    result
  end

  defp ping_heartbeat do
    if @heartbeat_url do
      Task.start(fn ->
        :httpc.request(:get, {String.to_charlist(@heartbeat_url), []}, [{:timeout, 5000}], [])
      end)
    end
  end
end

Create the heartbeat monitor in Vigilmon:

  1. Go to Monitors → New → Heartbeat
  2. Set grace period to the expected NIF call period + 50% buffer (e.g. if called every 60 s, set 90 s)
  3. Copy the ping URL into VIGILMON_NIF_HEARTBEAT_URL

If the dirty scheduler thread deadlocks, the heartbeat stops and Vigilmon pages you before your users notice.


Step 5: Key metrics to alert on

| Metric | Alert threshold | Why | |---|---|---| | /health HTTP status | Non-200 for 2 checks | Full application down | | nif_health keyword "degraded" | Any match | NIF error or latency regression | | avg_duration_ms in response | > 50 ms | Scheduler blocking risk | | slow_calls in response | > 10 | Repeated latency spikes | | Dirty NIF heartbeat silence | Grace period exceeded | Scheduler thread hung |


Step 6: Status page

  1. In Vigilmon, go to Status Pages → New
  2. Add your HTTP health monitor and dirty NIF heartbeat
  3. Label them: "API (Web)" and "NIF Compute Layer"
  4. Share the URL with your engineering team

Conclusion

Zigler makes writing high-performance Zig NIFs from Elixir approachable, but NIFs operate below the BEAM supervision tree — a crash takes the whole VM down. With Vigilmon you get:

  • HTTP uptime checks that detect BEAM crashes and NIF degradation in your health endpoint
  • Keyword alerts that surface slow or error-prone NIFs before they cause outages
  • Heartbeat monitors that catch dirty scheduler thread deadlocks

Instrument NIF calls with telemetry first, then add monitoring as your Zig NIF surface area grows.

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 →