How to Monitor Phoenix.Socket with Vigilmon
Phoenix.Socket is the low-level transport abstraction that underlies all real-time communication in Phoenix. It manages the lifecycle of each WebSocket (or long-polling) connection, handles the connect/3 callback that authenticates incoming clients, routes incoming messages to the correct Phoenix.Channel, and tracks the connection's identity through id/1. Every Phoenix.Channel connection flows through a Socket — the Socket is the door; channels are the rooms inside.
Because Phoenix.Socket operates one layer below Phoenix.Channel, failures here are more fundamental: a broken Socket means no channel join requests can succeed at all. A misconfigured connect/3 that always returns :error silently rejects every WebSocket client. A Socket process crash that isn't supervised correctly causes disconnections without the visibility you'd get from a crashing Channel. Standard HTTP monitors see the Phoenix endpoint as alive even when Socket connections are failing at the transport layer.
Vigilmon lets you verify that the Socket transport layer accepts connections and authenticates clients end-to-end.
Why Monitor Phoenix.Socket?
Socket failures are invisible to HTTP uptime monitors and often invisible to users until they notice real-time features stop working:
connect/3authentication breakage — a code change that breaks token verification inconnect/3silently rejects every WebSocket client; HTTP endpoints continue to return 200- WebSocket upgrade failure — a misconfigured reverse proxy that strips Upgrade headers causes WebSocket connections to fall back to long-polling or fail entirely without HTTP error indicators
- Socket process memory leak — Socket processes accumulate assigns or state in custom
connect/3callbacks; a leak across thousands of connections exhausts BEAM memory - Transport configuration drift — Phoenix supports
:websocketand:longpolltransports; disabling a transport in configuration breaks clients that depend on it without a clear error signal - Rapid reconnect storm — when a Socket is closed unexpectedly, all connected clients reconnect simultaneously, causing a thundering herd that saturates the connection pool
id/1namespace collision — ifid/1returns the same value for different users, disconnecting one user disconnects all with the same ID
Key Metrics to Track
| Metric | What it tells you |
|--------|-------------------|
| Socket connection accept rate | Whether connect/3 is authenticating clients successfully |
| connect/3 rejection rate | Authentication failures, token expiry, or config breakage |
| Active Socket count | Current connection load on the transport layer |
| Socket process memory | Per-connection state accumulation |
| WebSocket upgrade success rate | Whether the transport layer is functioning |
| Heartbeat miss on Socket liveness | Whether the Socket pipeline accepts connections end-to-end |
Step 1: Verify Phoenix.Socket Configuration
Confirm your Socket is registered in the Endpoint and transports are configured explicitly:
# lib/my_app_web/endpoint.ex
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
socket "/socket", MyAppWeb.UserSocket,
websocket: [timeout: 45_000],
longpoll: false
# ... plugs
end
Your Socket module should authenticate in connect/3 and return a unique identity in id/1:
# lib/my_app_web/channels/user_socket.ex
defmodule MyAppWeb.UserSocket do
use Phoenix.Socket
require Logger
channel "room:*", MyAppWeb.RoomChannel
channel "notifications:*", MyAppWeb.NotificationChannel
@impl true
def connect(%{"token" => token}, socket, connect_info) do
start = System.monotonic_time(:millisecond)
result =
case Phoenix.Token.verify(socket, "user_socket", token, max_age: 86_400) do
{:ok, user_id} ->
{:ok, assign(socket, :user_id, user_id)}
{:error, reason} ->
Logger.warning("[Socket] connect rejected: #{reason}",
remote_ip: connect_info[:peer_data][:address] |> inspect()
)
:error
end
duration_ms = System.monotonic_time(:millisecond) - start
outcome = if match?({:ok, _}, result), do: "ok", else: "rejected"
:telemetry.execute(
[:my_app, :socket, :connect],
%{duration_ms: duration_ms},
%{outcome: outcome}
)
result
end
def connect(_params, _socket, _connect_info), do: :error
@impl true
def id(socket), do: "user_socket:#{socket.assigns.user_id}"
end
Step 2: Instrument Socket Telemetry
Attach telemetry handlers to track connection outcomes over time:
# lib/my_app/application.ex
def start(_type, _args) do
:telemetry.attach_many(
"my-app-socket-handler",
[
[:my_app, :socket, :connect],
[:phoenix, :socket_connected],
[:phoenix, :socket_disconnected]
],
&MyApp.SocketTelemetry.handle_event/4,
nil
)
# ... rest of start/2
end
# lib/my_app/socket_telemetry.ex
defmodule MyApp.SocketTelemetry do
require Logger
def handle_event([:my_app, :socket, :connect], %{duration_ms: ms}, %{outcome: outcome}, _) do
Logger.debug("[Socket] connect #{outcome} in #{ms}ms")
end
def handle_event([:phoenix, :socket_connected], _measurements, meta, _) do
Logger.debug("[Socket] connected: #{meta[:transport]}")
end
def handle_event([:phoenix, :socket_disconnected], _measurements, meta, _) do
Logger.debug("[Socket] disconnected: #{meta[:transport]}")
end
end
Step 3: Build a Socket Health Check
Create a health probe Socket and a GenServer that performs a full connect-and-ping check using an in-process WebSocket client:
# lib/my_app_web/channels/user_socket.ex — add a probe channel route
channel "health:*", MyAppWeb.HealthChannel
# lib/my_app_web/channels/health_channel.ex
defmodule MyAppWeb.HealthChannel do
use Phoenix.Channel
@impl true
def join("health:probe", _payload, socket) do
{:ok, socket}
end
@impl true
def handle_in("ping", %{"probe_id" => probe_id}, socket) do
push(socket, "pong", %{"probe_id" => probe_id})
{:noreply, socket}
end
end
# lib/my_app/socket_health.ex
defmodule MyApp.SocketHealth do
@moduledoc """
Checks that the Socket transport layer accepts connections end-to-end.
Generates a health token, connects a test Socket, joins the health channel,
sends a ping, and asserts it receives a pong within the timeout.
"""
@timeout 5_000
def check do
# Generate a short-lived token for the health probe
token =
Phoenix.Token.sign(
MyAppWeb.Endpoint,
"user_socket",
0,
signed_at: System.system_time(:second)
)
start = System.monotonic_time(:millisecond)
result =
try do
{:ok, socket} =
Phoenix.ChannelTest.connect(MyAppWeb.UserSocket, %{"token" => token}, %{})
{:ok, _, channel} =
Phoenix.ChannelTest.subscribe_and_join(socket, "health:probe", %{})
probe_id = :crypto.strong_rand_bytes(4) |> Base.encode16()
Phoenix.ChannelTest.push(channel, "ping", %{"probe_id" => probe_id})
receive do
%Phoenix.Socket.Message{event: "pong", payload: %{"probe_id" => ^probe_id}} ->
latency_ms = System.monotonic_time(:millisecond) - start
Phoenix.ChannelTest.close(channel)
{:ok, latency_ms}
after
@timeout ->
{:error, :timeout}
end
rescue
e -> {:error, inspect(e)}
end
:telemetry.execute(
[:my_app, :socket, :health_check],
%{duration_ms: System.monotonic_time(:millisecond) - start},
%{outcome: if(match?({:ok, _}, result), do: "ok", else: "error")}
)
result
end
end
Step 4: Expose a Health Endpoint
# lib/my_app_web/controllers/health_controller.ex
defmodule MyAppWeb.HealthController do
use MyAppWeb, :controller
def index(conn, _params) do
socket_check = MyApp.SocketHealth.check()
db_check = check_db()
checks = %{
socket: format_check(socket_check),
database: format_check(db_check)
}
status =
if match?({:ok, _}, socket_check) and db_check == :ok, do: 200, else: 503
conn
|> put_status(status)
|> json(%{
status: if(status == 200, do: "ok", else: "degraded"),
checks: checks
})
end
defp check_db do
case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", []) do
{:ok, _} -> :ok
_ -> :error
end
rescue
_ -> :error
end
defp format_check({:ok, latency_ms}), do: %{status: "ok", latency_ms: latency_ms}
defp format_check({:error, reason}), do: %{status: "error", reason: inspect(reason)}
defp format_check(:ok), do: %{status: "ok"}
defp format_check(:error), do: %{status: "error"}
end
Step 5: Liveness Heartbeat
Add a GenServer that periodically tests the Socket pipeline and pings Vigilmon on success:
# lib/my_app/socket_heartbeat.ex
defmodule MyApp.SocketHeartbeat do
use GenServer
require Logger
@heartbeat_url System.get_env("VIGILMON_SOCKET_HEARTBEAT_URL")
@interval :timer.minutes(1)
def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
def init(state) do
schedule()
{:ok, state}
end
def handle_info(:check, state) do
case MyApp.SocketHealth.check() do
{:ok, latency_ms} ->
Logger.debug("[SocketHeartbeat] Socket healthy (#{latency_ms}ms)")
ping_vigilmon()
{:error, reason} ->
Logger.error("[SocketHeartbeat] Socket health check failed: #{inspect(reason)}")
end
schedule()
{:noreply, state}
end
defp ping_vigilmon do
if @heartbeat_url do
:httpc.request(
:get,
{String.to_charlist(@heartbeat_url), []},
[{:timeout, 5_000}],
[]
)
end
end
defp schedule, do: Process.send_after(self(), :check, @interval)
end
Add to your supervision tree:
# lib/my_app/application.ex
children = [
MyApp.Repo,
MyAppWeb.Endpoint,
MyApp.SocketHeartbeat
]
Step 6: Set Up Monitoring in Vigilmon
HTTP Monitor (Socket health endpoint)
- Sign in at vigilmon.online
- Click New Monitor → HTTP
- Set URL to
https://your-app.example.com/health - Expected status:
200 - Body must contain:
"socket":{"status":"ok"} - Check interval: 60 seconds
Heartbeat Monitor (Socket liveness)
- Click New Monitor → Heartbeat
- Name:
Phoenix.Socket Liveness - Expected interval: 2 minutes
- Copy the URL → set as
VIGILMON_SOCKET_HEARTBEAT_URLin your environment
A missed heartbeat means the Socket pipeline cannot accept connections — every WebSocket client in your application is affected.
Step 7: Alerting
In Vigilmon, configure Notifications → New Channel:
Slack for Socket health failure:
🔴 DOWN: Phoenix.Socket — MyApp
Monitor: /health returning socket.status = "error"
Action: Check UserSocket connect/3, SECRET_KEY_BASE, and reverse proxy WebSocket config
PagerDuty for heartbeat miss:
A missed heartbeat on Socket liveness means all real-time features are broken for connected clients. Configure a high-priority PagerDuty integration in Vigilmon's notification settings.
Alert on high connection rejection rate (Prometheus/PromEx):
- alert: PhoenixSocketRejectRateHigh
expr: |
rate(my_app_socket_connect_count{outcome="rejected"}[5m]) /
rate(my_app_socket_connect_count[5m]) > 0.1
for: 2m
annotations:
summary: "Socket rejection rate above 10% — possible token expiry or auth breakage"
Common Phoenix.Socket Issues and Fixes
Reverse proxy stripping Upgrade headers:
# nginx — required for WebSocket upgrade
location /socket {
proxy_pass http://phoenix_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
connect/3 always returning :error after token salt change:
# Ensure the salt used to sign the token in your client matches
# the salt used in Phoenix.Token.verify/4 inside connect/3
Phoenix.Token.verify(socket, "user_socket", token, max_age: 86_400)
# ^^^^^^^^^^^^^
# Must match the salt used in Phoenix.Token.sign/4
Socket process memory accumulation:
# Avoid storing large binaries in socket.assigns inside connect/3
# Fetch user data lazily inside channels; only assign minimal identifiers here
@impl true
def connect(%{"token" => token}, socket, _) do
case verify_token(token) do
{:ok, user_id} -> {:ok, assign(socket, :user_id, user_id)}
# ^^^^^^^^^^^^^^^^^^^
# Only the ID — not the full user struct
_ -> :error
end
end
Thundering herd on reconnect:
// Client-side: use exponential backoff to spread reconnects
socket = new Phoenix.Socket("/socket", {
reconnectAfterMs: attempt => [1000, 2000, 5000, 10000][attempt - 1] || 10000
})
What You've Built
| What | How |
|------|-----|
| Socket connect instrumentation | Telemetry counter with outcome tag on every connect/3 call |
| End-to-end Socket health check | In-process connect → channel join → ping/pong |
| Structured health endpoint | JSON response verifying Socket transport layer |
| Liveness heartbeat | GenServer pinging Vigilmon only on successful Socket health check |
| HTTP monitor | Vigilmon checks /health every 60 seconds |
| Real-time alerting | Slack for endpoint failure, PagerDuty for heartbeat miss |
Phoenix.Socket is the foundation of every real-time feature in your application. Vigilmon makes sure it stays solid when your users depend on it.
Next Steps
- Add per-transport (
websocketvslongpoll) breakdowns in your telemetry tags - Set up Vigilmon's incident history to correlate Socket rejection spikes with deployments
- Monitor Socket liveness separately per environment (staging, prod) with distinct heartbeat monitors
- Use Vigilmon response-time checks to detect Socket health check latency before it affects UX
Get started free at vigilmon.online.