tutorial

How to Monitor VintageNet with Vigilmon

Track network interface state, connection health, failover events, and WiFi signal quality on Nerves-based Elixir embedded Linux devices with VintageNet and Vigilmon.

How to Monitor VintageNet with Vigilmon

VintageNet is the networking configuration and management library for Nerves-based Elixir embedded Linux devices. It handles WiFi, Ethernet, LTE, and VPN connections through a unified API, manages interface state machines, and provides automatic failover when a primary network interface goes down. It replaces traditional Linux network management tools (NetworkManager, wpa_supplicant scripts) with a supervised Elixir process tree that integrates cleanly with the BEAM.

Embedded devices live in environments that software services don't: RF interference degrades WiFi signal without dropping the connection entirely, LTE links have intermittent coverage, Ethernet cables get unplugged and replugged, and failover between interfaces introduces brief connectivity gaps. Without monitoring, these network events are invisible until a remote device stops responding to commands, fails to deliver sensor data, or misses its OTA update window.

Vigilmon gives your Nerves device external network observability: detect interface outages, track failover events, and alert when a remote device goes silent before your users or operators notice.


Why Monitor VintageNet on Embedded Devices?

Network failures on embedded devices have higher blast radius than on servers:

  • Silent connectivity loss — WiFi can stay "associated" at the driver level while routing is broken; the device appears up but can't reach any external service
  • Failover storms — a misconfigured priority order triggers constant failover between interfaces, consuming power and causing periodic connectivity gaps
  • LTE signal degradation — RSSI drops over days as signal conditions change; the connection stays up but latency becomes too high for real-time applications
  • DHCP lease failure — an interface connects but fails to get an IP address; VintageNet reports the interface as :configured but routing doesn't work
  • DNS resolution failures — interface is up and IP is assigned but the DNS resolver isn't reachable; the device can ping by IP but not by hostname
  • Device unreachability — network issues prevent OTA updates, telemetry delivery, and remote management commands from reaching the device

Key Metrics to Track

| Metric | What it tells you | |--------|-------------------| | Interface state by name | Whether each interface is :configured, :disconnected, or in an error state | | Active interface (current route) | Which interface is currently handling traffic | | Failover event count | How often the active interface switches — high counts indicate instability | | WiFi RSSI | Signal strength in dBm — declining RSSI predicts impending disconnection | | WiFi SSID and BSSID | Whether the device roamed to a different access point | | LTE signal quality (RSRP/RSRQ) | LTE signal strength for cellular-connected devices | | IP address assignment time | How long DHCP takes after an interface connects | | Heartbeat interval | Whether the device successfully phoned home within the expected window |


Step 1: Subscribe to VintageNet Property Changes

VintageNet exposes all network state through a property-based API (VintageNet.PropertyTable). Subscribe to interface state changes to instrument events:

# lib/my_app/network_monitor.ex
defmodule MyApp.NetworkMonitor do
  use GenServer
  require Logger

  @interfaces ["eth0", "wlan0", "ppp0"]

  def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)

  def init(state) do
    # Subscribe to state changes for all managed interfaces
    Enum.each(@interfaces, fn iface ->
      VintageNet.subscribe(["interface", iface, "connection"])
      VintageNet.subscribe(["interface", iface, "addresses"])
      VintageNet.subscribe(["interface", iface, "wifi", "rssi"])
    end)

    VintageNet.subscribe(["interface", "eth0", "wifi", "access_point_info"])

    initial_state =
      Map.new(@interfaces, fn iface ->
        connection = VintageNet.get(["interface", iface, "connection"])
        {iface, %{connection: connection, address: nil, last_transition: System.system_time(:second)}}
      end)

    {:ok, initial_state}
  end

  def handle_info(
        {VintageNet, ["interface", iface, "connection"], old_value, new_value, _meta},
        state
      ) do
    Logger.info("Network interface state changed",
      interface: iface,
      from: inspect(old_value),
      to: inspect(new_value)
    )

    :telemetry.execute(
      [:my_app, :network, :interface_state_change],
      %{count: 1},
      %{interface: iface, from: old_value, to: new_value}
    )

    now = System.system_time(:second)

    updated =
      Map.update(state, iface, %{connection: new_value, address: nil, last_transition: now}, fn s ->
        %{s | connection: new_value, last_transition: now}
      end)

    {:noreply, updated}
  end

  def handle_info(
        {VintageNet, ["interface", iface, "wifi", "rssi"], _old, rssi, _meta},
        state
      ) do
    :telemetry.execute(
      [:my_app, :network, :wifi_rssi],
      %{rssi: rssi || 0},
      %{interface: iface}
    )

    {:noreply, state}
  end

  def handle_info(
        {VintageNet, ["interface", iface, "addresses"], _old, addresses, _meta},
        state
      ) do
    ip =
      case addresses do
        [%{address: addr} | _] -> :inet.ntoa(addr) |> to_string()
        _ -> nil
      end

    Logger.info("Interface IP address changed", interface: iface, ip: ip)

    updated = Map.update(state, iface, %{connection: nil, address: ip, last_transition: nil}, fn s ->
      %{s | address: ip}
    end)

    {:noreply, updated}
  end

  def handle_info(_msg, state), do: {:noreply, state}

  def current_status do
    GenServer.call(__MODULE__, :status)
  end

  def handle_call(:status, _from, state), do: {:reply, state, state}
end

Step 2: Detect Failover Events

Track when the active (default-route) interface switches:

# lib/my_app/failover_tracker.ex
defmodule MyApp.FailoverTracker do
  use GenServer
  require Logger

  def start_link(_), do: GenServer.start_link(__MODULE__, %{active: nil, failover_count: 0}, name: __MODULE__)

  def init(state) do
    VintageNet.subscribe(["connection"])
    current = VintageNet.get(["connection"])
    {:ok, %{state | active: current}}
  end

  def handle_info({VintageNet, ["connection"], old_iface, new_iface, _meta}, state) do
    new_count = state.failover_count + 1

    Logger.info("Network failover event",
      from_interface: inspect(old_iface),
      to_interface: inspect(new_iface),
      total_failovers: new_count
    )

    :telemetry.execute(
      [:my_app, :network, :failover],
      %{count: 1, total: new_count},
      %{from: inspect(old_iface), to: inspect(new_iface)}
    )

    {:noreply, %{state | active: new_iface, failover_count: new_count}}
  end

  def handle_info(_msg, state), do: {:noreply, state}

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

  def handle_call(:failover_count, _from, state), do: {:reply, state.failover_count, state}
end

Step 3: Define Telemetry Metrics

# lib/my_app/telemetry.ex
def metrics do
  [
    # Interface state transitions
    counter("my_app.network.interface_state_change.count",
      tags: [:interface, :from, :to]
    ),

    # WiFi signal strength
    last_value("my_app.network.wifi_rssi.rssi",
      tags: [:interface],
      unit: :dbm
    ),

    # Failover events
    counter("my_app.network.failover.count",
      tags: [:from, :to]
    ),
    last_value("my_app.network.failover.total",
      tags: [:from, :to]
    )
  ]
end

Step 4: Health Endpoint

Expose network state through a health check endpoint that Vigilmon can poll:

# lib/my_app_web/controllers/health_controller.ex
defmodule MyAppWeb.HealthController do
  use MyAppWeb, :controller

  def index(conn, _params) do
    network_status = MyApp.NetworkMonitor.current_status()

    eth0 = network_status["eth0"]
    wlan0 = network_status["wlan0"]
    active_connection = VintageNet.get(["connection"])
    wifi_rssi = VintageNet.get(["interface", "wlan0", "wifi", "rssi"])

    checks = %{
      network: %{
        active_interface: inspect(active_connection),
        eth0: format_interface(eth0),
        wlan0: format_interface(wlan0),
        wifi_rssi_dbm: wifi_rssi
      }
    }

    network_ok =
      [eth0, wlan0]
      |> Enum.filter(& &1)
      |> Enum.any?(fn s -> s.connection == :internet end)

    status = if network_ok, do: 200, else: 503

    conn
    |> put_status(status)
    |> json(%{status: if(status == 200, do: "ok", else: "degraded"), checks: checks})
  end

  defp format_interface(nil), do: %{status: "unknown"}
  defp format_interface(%{connection: conn, address: addr}) do
    %{connection: inspect(conn), address: addr}
  end
end

Step 5: Heartbeat Monitor for Device Liveness

The most critical monitoring for embedded devices: a heartbeat that proves the device is alive and network-connected. If the device goes silent, Vigilmon alerts before operators notice.

# lib/my_app/vigilmon_heartbeat.ex
defmodule MyApp.VigilmonHeartbeat do
  use GenServer
  require Logger

  @heartbeat_url System.get_env("VIGILMON_HEARTBEAT_URL")
  @interval :timer.minutes(5)

  def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)

  def init(state) do
    schedule()
    {:ok, state}
  end

  def handle_info(:ping, state) do
    case ping_vigilmon() do
      {:ok, _} ->
        Logger.debug("Vigilmon heartbeat sent")

      {:error, reason} ->
        Logger.warning("Vigilmon heartbeat failed", reason: inspect(reason))
    end

    schedule()
    {:noreply, state}
  end

  defp ping_vigilmon do
    if @heartbeat_url do
      :httpc.request(:get, {String.to_charlist(@heartbeat_url), []}, [{:timeout, 10_000}], [])
    else
      {:error, :no_heartbeat_url}
    end
  end

  defp schedule, do: Process.send_after(self(), :ping, @interval)
end

Add to your supervision tree:

# lib/my_app/application.ex
children = [
  MyApp.NetworkMonitor,
  MyApp.FailoverTracker,
  MyApp.VigilmonHeartbeat,
  MyAppWeb.Endpoint
]

Set the environment variable in your Nerves firmware configuration:

# config/target.exs
config :my_app,
  vigilmon_heartbeat_url: System.get_env("VIGILMON_HEARTBEAT_URL")

Step 6: Set Up Monitors in Vigilmon

Heartbeat monitor (primary):

  1. Sign in at vigilmon.online
  2. Click New Monitor → Heartbeat
  3. Set expected interval to 8 minutes (5-minute ping interval + 3-minute grace)
  4. Copy the ping URL and set it as VIGILMON_HEARTBEAT_URL in your firmware environment
  5. Enable PagerDuty or SMS alerting — a missed heartbeat from an embedded device is an emergency

HTTP monitor (if your device has an accessible endpoint):

  1. Click New Monitor → HTTP
  2. URL: http://device-hostname.local/health or external IP
  3. Interval: 60 seconds
  4. Alert on any non-200 response

Step 7: Alerting

Configure notifications in Vigilmon → Notifications → New Channel:

Slack alert for device offline:

🔴 DOWN: device-serial-0042 heartbeat
Last seen: 8 minutes ago
Action: Check device power, network connection, and LTE signal

PagerDuty for production devices:

Page on-call immediately when the heartbeat monitor misses two consecutive expected pings. For industrial, medical, or commercial embedded deployments, a silent device can mean data loss, physical system failure, or safety risk.

WiFi signal degradation alert:

# In your anomaly checker, emit an event when RSSI drops below threshold
defmodule MyApp.WifiQualityChecker do
  use GenServer

  @weak_signal_dbm -75
  @critical_signal_dbm -85
  @check_interval :timer.minutes(1)

  def init(state) do
    schedule()
    {:ok, state}
  end

  def handle_info(:check, state) do
    rssi = VintageNet.get(["interface", "wlan0", "wifi", "rssi"])

    cond do
      is_nil(rssi) ->
        :ok

      rssi < @critical_signal_dbm ->
        :telemetry.execute(
          [:my_app, :network, :wifi_quality_alert],
          %{rssi: rssi},
          %{level: :critical}
        )

      rssi < @weak_signal_dbm ->
        :telemetry.execute(
          [:my_app, :network, :wifi_quality_alert],
          %{rssi: rssi},
          %{level: :warning}
        )

      true ->
        :ok
    end

    schedule()
    {:noreply, state}
  end

  defp schedule, do: Process.send_after(self(), :check, @check_interval)
end

What You've Built

| What | How | |------|-----| | Interface state monitoring | VintageNet property subscriptions on connection and address | | WiFi RSSI tracking | Property subscription on wifi.rssi with telemetry | | Failover event detection | Subscription on global connection property | | Health endpoint | JSON response reporting per-interface state | | Device liveness heartbeat | GenServer pinging Vigilmon every 5 minutes | | Slack + PagerDuty alerts | Vigilmon heartbeat and HTTP monitors with notification channels | | WiFi quality alerting | Threshold-based RSSI checker emitting telemetry events |

VintageNet manages your device's connectivity. Vigilmon ensures that connectivity is actually working — alerting you when a device goes silent, a failover storm starts, or WiFi signal degrades below safe operating levels, before the device misses a critical update or stops delivering data.


Next Steps

  • Add LTE signal metrics (RSRP/RSRQ) via VintageNet.get(["interface", "ppp0", "mobile", "signal"]) for cellular-connected devices
  • Track IP address lease times and alert when DHCP renewal starts taking longer than expected
  • Use Vigilmon's response time history to detect when round-trip latency to your device increases, predicting impending network degradation
  • For fleets, create one Vigilmon heartbeat monitor per device and group them in a status page for your operations team

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 →