tutorial

How to Monitor Phoenix.Socket with Vigilmon

Track Phoenix.Socket connection lifecycle, transport health, authentication failure rates, and channel routing in your Elixir application, and use Vigilmon to alert when WebSocket connectivity degrades.

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/3 authentication breakage — a code change that breaks token verification in connect/3 silently 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/3 callbacks; a leak across thousands of connections exhausts BEAM memory
  • Transport configuration drift — Phoenix supports :websocket and :longpoll transports; 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/1 namespace collision — if id/1 returns 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)

  1. Sign in at vigilmon.online
  2. Click New Monitor → HTTP
  3. Set URL to https://your-app.example.com/health
  4. Expected status: 200
  5. Body must contain: "socket":{"status":"ok"}
  6. Check interval: 60 seconds

Heartbeat Monitor (Socket liveness)

  1. Click New Monitor → Heartbeat
  2. Name: Phoenix.Socket Liveness
  3. Expected interval: 2 minutes
  4. Copy the URL → set as VIGILMON_SOCKET_HEARTBEAT_URL in 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 (websocket vs longpoll) 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.

Monitor your app with Vigilmon

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

Start free →