How to Monitor Poison (Elixir JSON) with Vigilmon
Poison is a pure Elixir JSON library — one of the first widely adopted JSON parsers in the Elixir ecosystem. It provides fast encoding and decoding with automatic struct support via the Poison.Encoder protocol, making it easy to serialize custom Elixir structs to JSON and parse JSON responses back into typed maps or structs. Many existing Elixir applications and libraries still depend on Poison, particularly those that predate Jason's rise to dominance.
But JSON serialization is a cross-cutting concern. A struct missing a Poison.Encoder implementation raises at encode time, not at definition time. A deeply nested payload that exceeds recursion limits crashes the encoder silently in older versions. An external API that changes its JSON shape causes silent data loss when decoded into a struct with strict field mapping. These failures are invisible without monitoring.
This tutorial adds production observability to an Elixir application using Poison:
- An application health check that verifies JSON encoding and decoding
- HTTP uptime monitoring with Vigilmon
- Encode/decode error rate tracking
- Heartbeat monitoring for JSON-heavy background workers
- Alerts on serialization failures
Step 1: Add Poison to your project
# mix.exs
defp deps do
[
{:poison, "~> 6.0"},
{:plug_cowboy, "~> 2.0"},
{:plug, "~> 1.14"},
{:req, "~> 0.4"}
]
end
Implement Poison.Encoder for your structs:
# lib/my_app/schemas/product.ex
defmodule MyApp.Product do
defstruct [:id, :name, :price, :category, :in_stock]
defimpl Poison.Encoder do
def encode(%MyApp.Product{} = product, options) do
Poison.Encoder.Map.encode(%{
id: product.id,
name: product.name,
price: product.price,
category: product.category,
in_stock: product.in_stock
}, options)
end
end
end
Or use the simpler @derive syntax for basic struct encoding:
defmodule MyApp.Order do
@derive [Poison.Encoder]
defstruct [:id, :customer_id, :total, :status, :line_items]
end
Step 2: Use Poison for encoding and decoding
# lib/my_app/api/external_api_client.ex
defmodule MyApp.Api.ExternalApiClient do
require Logger
@base_url "https://api.example.com"
def get_orders(customer_id) do
url = "#{@base_url}/customers/#{customer_id}/orders"
case Req.get(url, headers: [{"accept", "application/json"}]) do
{:ok, %{status: 200, body: body}} ->
decode_orders(body)
{:ok, %{status: status}} ->
{:error, "unexpected status #{status}"}
{:error, reason} ->
{:error, reason}
end
end
def post_order(order) do
case Poison.encode(order) do
{:ok, json} ->
Req.post("#{@base_url}/orders",
body: json,
headers: [{"content-type", "application/json"}]
)
{:error, reason} ->
Logger.error("Failed to encode order: #{inspect(reason)}")
{:error, :encode_failed}
end
end
defp decode_orders(body) when is_binary(body) do
case Poison.decode(body, as: %{"orders" => [%MyApp.Order{}]}) do
{:ok, %{"orders" => orders}} ->
{:ok, orders}
{:error, reason} ->
Logger.error("Failed to decode orders response: #{inspect(reason)}")
{:error, :decode_failed}
end
end
defp decode_orders(body) when is_map(body) do
# Req already decoded JSON — wrap in expected shape
{:ok, Map.get(body, "orders", [])}
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(),
json: check_json(),
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, Poison.encode!(%{
status: (if status == 200, do: "ok", else: "degraded"),
checks: checks,
json_stats: MyApp.JsonMetrics.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_json do
# Round-trip a struct through Poison encode → decode
probe = %MyApp.Product{
id: 0,
name: "health_probe",
price: 0.0,
category: "probe",
in_stock: true
}
with {:ok, json} <- Poison.encode(probe),
{:ok, decoded} <- Poison.decode(json),
"health_probe" <- Map.get(decoded, "name") 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","json":"ok","memory":"ok"},"json_stats":{"encodes":1420,"decodes":2310,"errors":0}}
Step 4: Track encode/decode error rates
# lib/my_app/json_metrics.ex
defmodule MyApp.JsonMetrics do
use Agent
def start_link(_), do: Agent.start_link(fn ->
%{encodes: 0, decodes: 0, encode_errors: 0, decode_errors: 0}
end, name: __MODULE__)
def record_encode(:ok),
do: Agent.update(__MODULE__, fn s -> Map.update!(s, :encodes, &(&1 + 1)) end)
def record_encode(:error),
do: Agent.update(__MODULE__, fn s -> Map.update!(s, :encode_errors, &(&1 + 1)) end)
def record_decode(:ok),
do: Agent.update(__MODULE__, fn s -> Map.update!(s, :decodes, &(&1 + 1)) end)
def record_decode(:error),
do: Agent.update(__MODULE__, fn s -> Map.update!(s, :decode_errors, &(&1 + 1)) end)
def stats do
Agent.get(__MODULE__, fn s ->
total = s.encodes + s.decodes
errors = s.encode_errors + s.decode_errors
error_rate = if total > 0, do: Float.round(errors / total * 100, 2), else: 0.0
Map.put(s, :error_rate_pct, error_rate)
end)
end
end
Wrap Poison calls to track outcomes:
# lib/my_app/api/external_api_client.ex — update post_order and decode_orders:
def post_order(order) do
case Poison.encode(order) do
{:ok, json} ->
MyApp.JsonMetrics.record_encode(:ok)
Req.post("#{@base_url}/orders", body: json, headers: [{"content-type", "application/json"}])
{:error, reason} ->
MyApp.JsonMetrics.record_encode(:error)
Logger.error("Encode failed: #{inspect(reason)}")
{:error, :encode_failed}
end
end
defp decode_orders(body) when is_binary(body) do
case Poison.decode(body) do
{:ok, decoded} ->
MyApp.JsonMetrics.record_decode(:ok)
{:ok, Map.get(decoded, "orders", [])}
{:error, reason} ->
MyApp.JsonMetrics.record_decode(:error)
Logger.error("Decode failed: #{inspect(reason)}")
{:error, :decode_failed}
end
end
Add MyApp.JsonMetrics to your supervision tree:
children = [
MyApp.Repo,
MyApp.JsonMetrics,
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
"json":"ok"to confirm serialization is healthy - Save
Why external monitoring matters for JSON-heavy applications:
Poison encode failures often surface as HTTP 500 errors on specific endpoints — not as a general application outage. Vigilmon's keyword check on the health endpoint catches serialization regressions early: if a struct loses its Poison.Encoder implementation after a refactor, the health check fails immediately and Vigilmon alerts before the error propagates to users.
Response time monitoring also helps: Poison encoding of deeply nested or large payloads can become a latency bottleneck. Vigilmon's response time history shows when encoding cost starts contributing to elevated endpoint latency.
Step 6: Heartbeat monitoring for JSON processing workers
# lib/my_app/workers/event_sync_worker.ex
defmodule MyApp.Workers.EventSyncWorker do
use GenServer
require Logger
@interval :timer.minutes(15)
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(:sync, state) do
Logger.info("Starting event sync...")
case sync_events() do
{:ok, count} ->
Logger.info("Synced #{count} events")
ping_heartbeat()
{:error, reason} ->
Logger.error("Event sync failed: #{inspect(reason)}")
end
schedule_tick()
{:noreply, state}
end
defp schedule_tick, do: Process.send_after(self(), :sync, @interval)
defp sync_events do
events = MyApp.Repo.all(MyApp.PendingEvent)
results =
Enum.map(events, fn event ->
case Poison.encode(event) do
{:ok, json} ->
MyApp.JsonMetrics.record_encode(:ok)
send_to_webhook(json, event.id)
{:error, reason} ->
MyApp.JsonMetrics.record_encode(:error)
Logger.error("Encode failed for event #{event.id}: #{inspect(reason)}")
:error
end
end)
success_count = Enum.count(results, &(&1 == :ok))
{:ok, success_count}
end
defp send_to_webhook(json, event_id) do
url = Application.get_env(:my_app, :webhook)[:url]
case Req.post(url, body: json, headers: [{"content-type", "application/json"}]) do
{:ok, %{status: s}} when s in 200..299 -> :ok
other ->
Logger.warning("Webhook delivery failed for #{event_id}: #{inspect(other)}")
:error
end
end
defp ping_heartbeat do
url = Application.get_env(:my_app, :vigilmon)[:event_sync_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 20 minutes (15-minute job with 5-minute grace)
- Copy the ping URL → set as
VIGILMON_EVENT_SYNC_HEARTBEAT_URLenv var - Configure via
Application.get_env(:my_app, :vigilmon)[:event_sync_heartbeat_url]
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. Poison-related incidents often look like:
- HTTP health check degraded (encoder probe failed — a struct broke after a refactor)
- Response time spike (large or deeply nested JSON payloads encoding slowly)
- Event sync heartbeat missed (encoder errors stopped the sync worker)
Configure Vigilmon to send all three alert types to your on-call channel so JSON serialization failures are caught before they affect external integrations.
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 Poison round-trip probe |
| Serialization verification | Encode → decode a Product struct in health check |
| Error rate tracking | JsonMetrics Agent (encodes, decodes, error rate) |
| HTTP uptime monitoring | Vigilmon HTTP monitor → /health with keyword check |
| Slack failure alerts | Vigilmon Slack notification channel |
| Event sync heartbeat | Heartbeat ping after each 15-minute sync cycle |
| Status page | Vigilmon public status page |
Poison serializes your data. Vigilmon makes sure your application stays healthy while it does.
Next steps
- If migrating from Poison to Jason, use the error rate tracker to confirm zero encode/decode errors in staging before cutting over production traffic
- Add per-struct encoding latency tracking to identify which Elixir structs are most expensive to serialize — large nested structs are common culprits
- Use Vigilmon's response time history to correlate JSON-heavy API endpoints with latency outliers and identify candidates for response streaming or pagination
- Set a Vigilmon alert on the health endpoint response time threshold to catch encoding bottlenecks before they become user-visible latency issues
Get started free at vigilmon.online.