tutorial

How to Monitor Poison (Elixir JSON) with Vigilmon

Learn how to add health checks, JSON serialization observability, and alerts to Elixir applications using the Poison library with Vigilmon.

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:

  1. Sign up at vigilmon.online
  2. Click New Monitor → HTTP
  3. Enter https://yourdomain.com/health
  4. Set check interval: 1 minute (paid) or 5 minutes (free)
  5. Under Advanced, add a keyword check for "json":"ok" to confirm serialization is healthy
  6. 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:

  1. New Monitor → Heartbeat
  2. Set interval to 20 minutes (15-minute job with 5-minute grace)
  3. Copy the ping URL → set as VIGILMON_EVENT_SYNC_HEARTBEAT_URL env var
  4. 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

  1. Status Pages → New Status Page in Vigilmon
  2. Add your HTTP health monitor and all heartbeat monitors
  3. Share the URL in your README and runbook

README badge:

![Uptime](https://vigilmon.online/badge/your-monitor-slug)

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.

Monitor your app with Vigilmon

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

Start free →