How to Monitor Msgpax with Vigilmon
Msgpax is a MessagePack implementation for Elixir that provides binary serialization more compact and faster than JSON. MessagePack encodes data in a binary format that can be 20–50% smaller than equivalent JSON, with significantly faster decode performance — making it ideal for high-throughput inter-service communication, cache storage, message queues, and WebSocket payloads where bandwidth and latency matter.
But binary serialization introduces failure modes that text-based formats don't have. A schema mismatch between a producer and consumer causes silent data corruption — a field that changed type decodes without error but produces wrong values. An oversized message that exceeds the unpacker's byte limit raises at decode time, not at send time. A MessagePack extension type that's registered in one service but not another causes opaque decode failures. These failures are invisible without monitoring.
This tutorial adds production observability to an Elixir application using Msgpax:
- An application health check that verifies MessagePack pack/unpack
- HTTP uptime monitoring with Vigilmon
- Pack/unpack error rate and payload size tracking
- Heartbeat monitoring for MessagePack-powered message workers
- Alerts on serialization failures
Step 1: Add Msgpax to your project
# mix.exs
defp deps do
[
{:msgpax, "~> 2.4"},
{:plug_cowboy, "~> 2.0"},
{:plug, "~> 1.14"},
{:jason, "~> 1.4"}, # still used for health check HTTP responses
{:req, "~> 0.4"}
]
end
Basic usage:
# Pack a map to binary
{:ok, packed} = Msgpax.pack(%{"user_id" => 42, "action" => "purchase"})
# packed is a binary (iolist — call IO.iodata_to_binary/1 to get a plain binary)
# Unpack binary back to a map
{:ok, unpacked} = Msgpax.unpack(IO.iodata_to_binary(packed))
# unpacked => %{"user_id" => 42, "action" => "purchase"}
For structs, implement the Msgpax.Packer protocol:
# lib/my_app/schemas/event.ex
defmodule MyApp.Event do
defstruct [:id, :type, :payload, :timestamp]
defimpl Msgpax.Packer do
def pack(%MyApp.Event{} = event, options) do
Msgpax.Packer.Map.pack(%{
"id" => event.id,
"type" => event.type,
"payload" => event.payload,
"timestamp" => DateTime.to_unix(event.timestamp)
}, options)
end
end
end
Step 2: Use Msgpax for inter-service messaging
# lib/my_app/messaging/event_publisher.ex
defmodule MyApp.Messaging.EventPublisher do
require Logger
@max_payload_bytes 1_048_576 # 1 MB limit
def publish(event) do
with {:ok, packed_iolist} <- Msgpax.pack(event),
packed = IO.iodata_to_binary(packed_iolist),
:ok <- check_payload_size(packed),
:ok <- send_to_queue(packed) do
MyApp.MsgpaxMetrics.record_pack(:ok, byte_size(packed))
{:ok, byte_size(packed)}
else
{:error, :payload_too_large} = err ->
MyApp.MsgpaxMetrics.record_pack(:error, 0)
Logger.error("Event payload exceeds #{@max_payload_bytes} bytes")
err
{:error, reason} ->
MyApp.MsgpaxMetrics.record_pack(:error, 0)
Logger.error("Pack failed: #{inspect(reason)}")
{:error, reason}
end
end
defp check_payload_size(packed) when byte_size(packed) > @max_payload_bytes,
do: {:error, :payload_too_large}
defp check_payload_size(_packed), do: :ok
defp send_to_queue(packed) do
url = Application.get_env(:my_app, :queue)[:endpoint_url]
case Req.post(url,
body: packed,
headers: [{"content-type", "application/msgpack"}]
) do
{:ok, %{status: s}} when s in 200..299 -> :ok
{:ok, %{status: status}} -> {:error, "queue rejected: #{status}"}
{:error, reason} -> {:error, reason}
end
end
end
# lib/my_app/messaging/event_consumer.ex
defmodule MyApp.Messaging.EventConsumer do
require Logger
def consume(packed_binary) when is_binary(packed_binary) do
case Msgpax.unpack(packed_binary) do
{:ok, event_map} ->
MyApp.MsgpaxMetrics.record_unpack(:ok, byte_size(packed_binary))
process_event(event_map)
{:error, reason} ->
MyApp.MsgpaxMetrics.record_unpack(:error, byte_size(packed_binary))
Logger.error("Unpack failed: #{inspect(reason)}")
{:error, :unpack_failed}
end
end
defp process_event(%{"type" => type} = event) do
Logger.info("Processing event type=#{type}")
# Dispatch to handlers...
{:ok, event}
end
defp process_event(event) do
Logger.warning("Unknown event shape: #{inspect(Map.keys(event))}")
{:error, :unknown_schema}
end
end
Step 3: Add a health check with serialization verification
# 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
checks = %{
database: check_database(),
msgpax: check_msgpax(),
memory: check_memory()
}
status = if Enum.all?(checks, fn {_, v} -> v == :ok end), do: 200, else: 503
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(%{
status: (if status == 200, do: "ok", else: "degraded"),
checks: checks,
msgpax_stats: MyApp.MsgpaxMetrics.stats()
}))
|> halt()
end
def call(conn, _opts), do: conn
defp check_database do
case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", []) do
{:ok, _} -> :ok
{:error, _} -> :error
end
rescue
_ -> :error
end
defp check_msgpax do
probe = %{"probe" => "health_check", "value" => 42}
with {:ok, packed_iolist} <- Msgpax.pack(probe),
packed = IO.iodata_to_binary(packed_iolist),
{:ok, unpacked} <- Msgpax.unpack(packed),
"health_check" <- Map.get(unpacked, "probe") do
:ok
else
_ -> :error
end
rescue
_ -> :error
end
defp check_memory do
total = :erlang.memory(:total)
if total < 2_147_483_648, do: :ok, else: :error
end
end
Mount it before your router:
# lib/my_app_web/endpoint.ex
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
plug MyAppWeb.Plugs.HealthCheck
plug MyAppWeb.Router
end
Test it:
curl http://localhost:4000/health
# {"status":"ok","checks":{"database":"ok","msgpax":"ok","memory":"ok"},"msgpax_stats":{"packs":840,"unpacks":840,"pack_errors":0,"unpack_errors":0,"avg_payload_bytes":128}}
Step 4: Track pack/unpack metrics and payload sizes
# lib/my_app/msgpax_metrics.ex
defmodule MyApp.MsgpaxMetrics do
use Agent
def start_link(_), do: Agent.start_link(fn ->
%{
packs: 0,
unpacks: 0,
pack_errors: 0,
unpack_errors: 0,
total_packed_bytes: 0,
total_unpacked_bytes: 0
}
end, name: __MODULE__)
def record_pack(:ok, bytes) do
Agent.update(__MODULE__, fn s ->
s
|> Map.update!(:packs, &(&1 + 1))
|> Map.update!(:total_packed_bytes, &(&1 + bytes))
end)
end
def record_pack(:error, _bytes) do
Agent.update(__MODULE__, fn s -> Map.update!(s, :pack_errors, &(&1 + 1)) end)
end
def record_unpack(:ok, bytes) do
Agent.update(__MODULE__, fn s ->
s
|> Map.update!(:unpacks, &(&1 + 1))
|> Map.update!(:total_unpacked_bytes, &(&1 + bytes))
end)
end
def record_unpack(:error, _bytes) do
Agent.update(__MODULE__, fn s -> Map.update!(s, :unpack_errors, &(&1 + 1)) end)
end
def stats do
Agent.get(__MODULE__, fn s ->
avg_pack_bytes =
if s.packs > 0, do: div(s.total_packed_bytes, s.packs), else: 0
avg_unpack_bytes =
if s.unpacks > 0, do: div(s.total_unpacked_bytes, s.unpacks), else: 0
s
|> Map.put(:avg_payload_bytes, avg_pack_bytes)
|> Map.put(:avg_received_bytes, avg_unpack_bytes)
end)
end
end
Add MyApp.MsgpaxMetrics to your supervision tree:
children = [
MyApp.Repo,
MyApp.MsgpaxMetrics,
MyAppWeb.Endpoint
]
Step 5: Set up HTTP monitoring in Vigilmon
Point Vigilmon at your health endpoint:
- Sign up at vigilmon.online
- Click New Monitor → HTTP
- Enter
https://yourdomain.com/health - Set check interval: 1 minute (paid) or 5 minutes (free)
- Under Advanced, add a keyword check for
"msgpax":"ok"to verify serialization health - Save
Why external monitoring matters for MessagePack applications:
Binary serialization schema mismatches are especially hard to detect because the unpack call may succeed but silently produce wrong data — the error surfaces as application-level data corruption rather than a serialization exception. Vigilmon's external check detects the infrastructure symptoms: if a schema mismatch causes consumer workers to crash and miss heartbeats, or if the health endpoint degrades, Vigilmon alerts before the corruption reaches downstream systems.
Response time monitoring also catches payload size growth: as MessagePack payloads grow over time (more fields added to events, larger schemas), pack/unpack latency increases and contributes to slower endpoint response times. Vigilmon's response time history shows the trend before it becomes an outage.
Step 6: Heartbeat monitoring for MessagePack message workers
# lib/my_app/workers/message_processing_worker.ex
defmodule MyApp.Workers.MessageProcessingWorker do
use GenServer
require Logger
@interval :timer.minutes(1)
def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
@impl true
def init(state) do
schedule_tick()
{:ok, state}
end
@impl true
def handle_info(:process, state) do
case fetch_and_process_messages() do
{:ok, count} when count > 0 ->
Logger.debug("Processed #{count} MessagePack messages")
ping_heartbeat()
{:ok, 0} ->
# No messages — still ping to confirm worker is alive
ping_heartbeat()
{:error, reason} ->
Logger.error("Message processing failed: #{inspect(reason)}")
# Don't ping — let Vigilmon detect the missed heartbeat
end
schedule_tick()
{:noreply, state}
end
defp schedule_tick, do: Process.send_after(self(), :process, @interval)
defp fetch_and_process_messages do
messages = MyApp.Repo.all(MyApp.PendingMessage)
results =
Enum.map(messages, fn msg ->
case MyApp.Messaging.EventConsumer.consume(msg.payload) do
{:ok, _} ->
MyApp.Repo.delete!(msg)
:ok
{:error, reason} ->
Logger.error("Failed to process message #{msg.id}: #{inspect(reason)}")
:error
end
end)
success_count = Enum.count(results, &(&1 == :ok))
{:ok, success_count}
end
defp ping_heartbeat do
url = Application.get_env(:my_app, :vigilmon)[:message_worker_heartbeat_url]
if url do
case Req.get(url, receive_timeout: 5_000) do
{:ok, _} -> :ok
{:error, reason} -> Logger.warning("Heartbeat ping failed: #{inspect(reason)}")
end
end
end
end
In Vigilmon:
- New Monitor → Heartbeat
- Set interval to 3 minutes (1-minute job with 2-minute grace)
- Copy the ping URL → set as
VIGILMON_MESSAGE_WORKER_HEARTBEAT_URLenv var - Start the worker in your supervision tree
Add to supervision:
children = [
MyApp.Repo,
MyApp.MsgpaxMetrics,
MyApp.Workers.MessageProcessingWorker,
MyAppWeb.Endpoint
]
Step 7: Alerts via Slack
In Vigilmon, go to Notifications → New Channel → Slack and paste your incoming webhook URL.
Enable the channel on all monitors. Msgpax-related incidents often look like:
- HTTP health check degraded (pack/unpack probe failed — often caused by a library version mismatch or corrupt message in the queue)
- Response time spike (average payload size grew as event schemas expanded)
- Message worker heartbeat missed (unpack errors caused the worker to stop processing)
Configure Vigilmon to send all three alert types to your on-call channel so binary serialization failures are caught before they corrupt downstream state.
Step 8: Status page
- Status Pages → New Status Page in Vigilmon
- Add your HTTP health monitor and all heartbeat monitors
- Share the URL in your
READMEand runbook
README badge:

What you've built
| What | How |
|------|-----|
| Health check plug | HealthCheck plug with Msgpax round-trip probe |
| Serialization verification | Pack → unpack a probe map in health check |
| Payload size tracking | MsgpaxMetrics Agent (bytes, averages, error rates) |
| HTTP uptime monitoring | Vigilmon HTTP monitor → /health with keyword check |
| Slack failure alerts | Vigilmon Slack notification channel |
| Message worker heartbeat | Heartbeat ping after each 1-minute processing cycle |
| Status page | Vigilmon public status page |
Msgpax keeps your inter-service payloads compact. Vigilmon keeps your message pipeline healthy.
Next steps
- Add schema version fields to your MessagePack payloads (
"schema_version" => 2) so consumers can detect mismatches before attempting to decode, and alert via Vigilmon when version drift is detected - Use Vigilmon's response time history to track payload size trends — a growing average payload size is an early warning that your MessagePack messages are accumulating unneeded fields
- Set a Vigilmon alert on the message worker heartbeat grace period to tighten it as your processing SLA improves — start with 3 minutes and reduce as the pipeline stabilizes
- Add a dead-letter queue monitor: track messages that fail to unpack and alert via Vigilmon heartbeat when the dead-letter count exceeds a threshold, before manual intervention is needed
Get started free at vigilmon.online.