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
- Log in at vigilmon.online and click New Monitor → HTTP.
- URL:
https://your-app.com/health. - Interval: 30 seconds (WebRTC signaling issues affect users immediately).
- Keyword check:
"ok". - Alert after 2 consecutive failures.
TURN server check
- Create a second HTTP monitor for
https://your-app.com/api/turn_health. - Interval: 60 seconds.
Active session heartbeat
- Go to Monitors → New → Heartbeat.
- Grace period: 60 seconds.
- Paste the ping URL into
@heartbeat_urlin 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.