tutorial

How to Monitor ExWebRTC with Vigilmon

Monitor native Elixir WebRTC signaling servers — track ICE negotiation success, DTLS handshake latency, peer connection state, and SDP signaling health with uptime and heartbeat checks.

How to Monitor ExWebRTC with Vigilmon

ExWebRTC is a native Elixir implementation of the WebRTC stack running entirely on the BEAM — no C bridges, no NIFs, no Node.js sidecar. It handles ICE agent negotiation, DTLS-SRTP key exchange, RTP/RTCP media transport, and SDP offer/answer signaling in pure Elixir processes.

Real-time communication adds a category of failure that HTTP monitoring alone won't catch: ICE candidates that time out without an error, DTLS handshakes that stall under load, and signaling servers that accept connections but fail to complete negotiation. You need monitoring that validates the full path — from HTTP signaling to a working peer connection.

This tutorial covers:

  • A health endpoint exposing WebRTC signaling state
  • HTTP uptime monitoring with Vigilmon
  • Heartbeat monitoring for long-running peer sessions
  • Alerts for ICE failure rates and DTLS handshake timeouts

Why Monitor ExWebRTC?

| Failure mode | Symptom without monitoring | |---|---| | STUN/TURN server unreachable | ICE gathering hangs; clients can't connect | | DTLS handshake timeout | Peers negotiate but never exchange media | | Signaling server OOM | New rooms fail; existing sessions unaffected | | ICE consent refresh failure | Established sessions silently drop after 30 s | | Codec negotiation mismatch | SDP exchange completes but no audio/video flows |


Step 1: Track WebRTC session state

Create a GenServer that subscribes to ExWebRTC telemetry events:

# lib/my_app/webrtc/session_monitor.ex
defmodule MyApp.WebRTC.SessionMonitor do
  use GenServer
  require Logger

  @doc "Returns a summary of active WebRTC session health."
  def session_health, do: GenServer.call(__MODULE__, :health)

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

  @impl true
  def init(_) do
    :telemetry.attach_many(
      "webrtc-session-monitor",
      [
        [:ex_webrtc, :peer_connection, :ice_candidate, :gathering_complete],
        [:ex_webrtc, :peer_connection, :ice_connection_state_change],
        [:ex_webrtc, :peer_connection, :dtls_transport, :state_change],
        [:ex_webrtc, :peer_connection, :connection_state_change],
      ],
      &__MODULE__.handle_telemetry/4,
      nil
    )

    {:ok, initial_state()}
  end

  defp initial_state do
    %{
      ice_connected: 0,
      ice_failed: 0,
      dtls_connected: 0,
      dtls_failed: 0,
      sessions_active: 0,
      sessions_closed: 0,
    }
  end

  def handle_telemetry([:ex_webrtc, :peer_connection, :ice_connection_state_change],
      _measurements, %{state: state}, _config) do
    GenServer.cast(__MODULE__, {:ice_state, state})
  end

  def handle_telemetry([:ex_webrtc, :peer_connection, :dtls_transport, :state_change],
      _measurements, %{state: state}, _config) do
    GenServer.cast(__MODULE__, {:dtls_state, state})
  end

  def handle_telemetry([:ex_webrtc, :peer_connection, :connection_state_change],
      _measurements, %{state: state}, _config) do
    GenServer.cast(__MODULE__, {:connection_state, state})
  end

  def handle_telemetry(_, _, _, _), do: :ok

  @impl true
  def handle_cast({:ice_state, :connected}, s),
    do: {:noreply, %{s | ice_connected: s.ice_connected + 1}}
  def handle_cast({:ice_state, :failed}, s),
    do: {:noreply, %{s | ice_failed: s.ice_failed + 1}}
  def handle_cast({:dtls_state, :connected}, s),
    do: {:noreply, %{s | dtls_connected: s.dtls_connected + 1}}
  def handle_cast({:dtls_state, :failed}, s),
    do: {:noreply, %{s | dtls_failed: s.dtls_failed + 1}}
  def handle_cast({:connection_state, :connected}, s),
    do: {:noreply, %{s | sessions_active: s.sessions_active + 1}}
  def handle_cast({:connection_state, :closed}, s),
    do: {:noreply, %{s | sessions_active: max(0, s.sessions_active - 1), sessions_closed: s.sessions_closed + 1}}
  def handle_cast(_, s), do: {:noreply, s}

  @impl true
  def handle_call(:health, _from, s) do
    ice_total = s.ice_connected + s.ice_failed
    dtls_total = s.dtls_connected + s.dtls_failed

    ice_ok = ice_total == 0 or s.ice_failed / ice_total < 0.1
    dtls_ok = dtls_total == 0 or s.dtls_failed / dtls_total < 0.1

    {:reply, Map.merge(s, %{ice_ok: ice_ok, dtls_ok: dtls_ok}), s}
  end
end

Add to your supervision tree:

# lib/my_app/application.ex
children = [
  MyApp.WebRTC.SessionMonitor,
  # ... other children
]

Step 2: Signaling server health 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
    webrtc = MyApp.WebRTC.SessionMonitor.session_health()
    db_ok = check_database()

    # Degraded if ICE or DTLS failure rate exceeds 10%
    webrtc_ok = webrtc.ice_ok and webrtc.dtls_ok

    status = if db_ok and webrtc_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"),
        webrtc_ice: if(webrtc.ice_ok, do: "ok", else: "degraded"),
        webrtc_dtls: if(webrtc.dtls_ok, do: "ok", else: "degraded"),
      },
      metrics: %{
        ice_connected: webrtc.ice_connected,
        ice_failed: webrtc.ice_failed,
        dtls_connected: webrtc.dtls_connected,
        dtls_failed: webrtc.dtls_failed,
        sessions_active: webrtc.sessions_active,
      }
    })

    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

Step 3: Room-level heartbeat for long sessions

For long-lived conference rooms or 1:1 sessions, send a heartbeat so Vigilmon knows the room is active:

# lib/my_app/webrtc/room.ex
defmodule MyApp.WebRTC.Room do
  use GenServer
  require Logger

  @heartbeat_url "https://vigilmon.online/api/heartbeats/YOUR_HEARTBEAT_ID/ping"
  @heartbeat_interval_ms 30_000

  def start_link(room_id),
    do: GenServer.start_link(__MODULE__, room_id, name: via(room_id))

  defp via(room_id), do: {:via, Registry, {MyApp.RoomRegistry, room_id}}

  @impl true
  def init(room_id) do
    schedule_heartbeat()
    Logger.info("Room #{room_id} started")
    {:ok, %{room_id: room_id, peers: %{}, started_at: System.monotonic_time(:second)}}
  end

  @impl true
  def handle_info(:heartbeat, state) do
    if map_size(state.peers) > 0 do
      # Only ping when there are active peers
      ping_heartbeat()
    end
    schedule_heartbeat()
    {:noreply, state}
  end

  defp schedule_heartbeat do
    Process.send_after(self(), :heartbeat, @heartbeat_interval_ms)
  end

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

In Vigilmon, create a Heartbeat monitor with grace period 60 seconds — if no room has active peers for over a minute and your system should always have active rooms, you'll get an alert.

Adjust the condition to match your use case: always-on broadcast servers should always have active peers; on-demand conferencing apps might only ping during business hours.


Step 4: TURN server availability check

If you run your own TURN server (e.g., Coturn), add a secondary Vigilmon monitor for it:

# lib/my_app_web/controllers/turn_health_controller.ex
defmodule MyAppWeb.TurnHealthController do
  use MyAppWeb, :controller

  @turn_host Application.compile_env(:my_app, [:turn, :host], "turn.example.com")
  @turn_port Application.compile_env(:my_app, [:turn, :port], 3478)

  def index(conn, _params) do
    case check_turn_reachability() do
      :ok ->
        json(conn, %{status: "ok", turn_host: @turn_host})
      {:error, reason} ->
        conn
        |> put_status(503)
        |> json(%{status: "error", reason: inspect(reason)})
    end
  end

  defp check_turn_reachability do
    # TCP connectivity check to TURN server
    case :gen_tcp.connect(String.to_charlist(@turn_host), @turn_port,
           [:binary, active: false], 3_000) do
      {:ok, socket} ->
        :gen_tcp.close(socket)
        :ok
      {:error, reason} ->
        {:error, reason}
    end
  end
end

Add to your router and create a second Vigilmon HTTP monitor for /api/turn_health.


Step 5: Configure Vigilmon

Signaling server uptime

  1. Log in at vigilmon.online and click New Monitor → HTTP.
  2. URL: https://your-app.com/health.
  3. Interval: 30 seconds (WebRTC signaling issues affect users immediately).
  4. Keyword check: "ok".
  5. Alert after 2 consecutive failures.

TURN server check

  1. Create a second HTTP monitor for https://your-app.com/api/turn_health.
  2. Interval: 60 seconds.

Active session heartbeat

  1. Go to Monitors → New → Heartbeat.
  2. Grace period: 60 seconds.
  3. Paste the ping URL into @heartbeat_url in your Room GenServer.

Step 6: Key metrics

| Metric | Alert threshold | Impact | |---|---|---| | /health HTTP status | Non-200 for 2 checks | Signaling down; no new connections | | webrtc_ice: "degraded" in body | Keyword alert | Connectivity failures > 10% | | webrtc_dtls: "degraded" | Keyword alert | Encryption failures; sessions drop | | TURN reachability | Non-200 for 2 checks | Symmetric NAT users can't connect | | Room heartbeat silence | > 60 s | Active room crashed or stalled |


Conclusion

ExWebRTC moves the entire WebRTC stack into the BEAM, which means all your observability tools can use Elixir-native telemetry — but it also means no external process to restart if the signaling layer degrades. With Vigilmon you get:

  • HTTP uptime checks that verify signaling server health and ICE/DTLS success rates
  • TURN server monitoring to catch NAT traversal failures before users report them
  • Heartbeat monitoring for long-lived conference rooms

Start with the signaling health endpoint, then add TURN monitoring once your ICE configuration is stable.

Monitor your app with Vigilmon

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

Start free →