tutorial

How to Monitor ExJsonSchema with Vigilmon

Monitor JSON Schema validation health, OpenAPI contract conformance, and API request/response validation in your Elixir app using ExJsonSchema and Vigilmon uptime monitoring.

How to Monitor ExJsonSchema with Vigilmon

ExJsonSchema is the standard JSON Schema validation library for Elixir, supporting draft 4, 6, and 7. It validates API request and response bodies against OpenAPI and JSON Schema specifications, providing detailed error reporting when payloads fail validation. When your schema validation pipeline is misconfigured, receiving unexpected payloads, or encountering resolution failures, invalid data silently passes through — or valid requests get rejected — and errors surface far from the root cause.

External monitoring catches schema validation health that your application's own error logs can't surface at a glance. This tutorial covers:

  • A health endpoint that validates ExJsonSchema schema loading and resolution
  • HTTP uptime monitoring with Vigilmon
  • Heartbeat monitoring for schema reload workers
  • Validation error rate tracking
  • Slack alerts and a status page

Step 1: Add ExJsonSchema to your project

# mix.exs
defp deps do
  [
    {:ex_json_schema, "~> 0.10"},
    {:req, "~> 0.4"}   # for heartbeat pings
  ]
end

Load and resolve a schema at startup:

# lib/my_app/schemas.ex
defmodule MyApp.Schemas do
  @user_schema %{
    "type" => "object",
    "required" => ["name", "email"],
    "properties" => %{
      "name" => %{"type" => "string", "minLength" => 1},
      "email" => %{"type" => "string", "format" => "email"},
      "age" => %{"type" => "integer", "minimum" => 0}
    }
  }

  def user_schema, do: ExJsonSchema.Schema.resolve(@user_schema)

  def validate_user(params) do
    case ExJsonSchema.Validator.validate(user_schema(), params) do
      :ok -> {:ok, params}
      {:error, errors} -> {:error, format_errors(errors)}
    end
  end

  defp format_errors(errors) do
    Enum.map(errors, fn {message, path} -> %{field: path, message: message} end)
  end
end

Use it in a controller:

defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller

  def create(conn, params) do
    case MyApp.Schemas.validate_user(params) do
      {:ok, valid_params} ->
        # proceed with valid_params
        json(conn, %{status: "created"})

      {:error, errors} ->
        conn
        |> put_status(422)
        |> json(%{status: "error", errors: errors})
    end
  end
end

Step 2: Build a health endpoint for ExJsonSchema

# lib/my_app_web/plugs/health_check.ex
defmodule MyAppWeb.Plugs.HealthCheck do
  import Plug.Conn
  require Logger

  def init(opts), do: opts

  def call(%Plug.Conn{request_path: "/health"} = conn, _opts) do
    checks = run_checks()
    status = if checks.status == "ok", do: 200, else: 503

    conn
    |> put_resp_content_type("application/json")
    |> send_resp(status, Jason.encode!(checks))
    |> halt()
  end

  def call(conn, _opts), do: conn

  defp run_checks do
    schema = schema_check()
    overall = schema.status == "ok"

    %{
      status: if(overall, do: "ok", else: "degraded"),
      json_schema: schema
    }
  end

  defp schema_check do
    start = System.monotonic_time(:millisecond)

    # Resolve and validate a known-good payload to confirm ExJsonSchema is operational
    result =
      try do
        probe_schema = %{
          "type" => "object",
          "required" => ["probe"],
          "properties" => %{"probe" => %{"type" => "boolean"}}
        }

        resolved = ExJsonSchema.Schema.resolve(probe_schema)

        case ExJsonSchema.Validator.validate(resolved, %{"probe" => true}) do
          :ok ->
            elapsed = System.monotonic_time(:millisecond) - start
            {:ok, elapsed}

          {:error, errors} ->
            {:error, "probe validation failed: #{inspect(errors)}"}
        end
      rescue
        e -> {:error, Exception.message(e)}
      end

    case result do
      {:ok, latency_ms} ->
        %{status: "ok", probe_latency_ms: latency_ms}

      {:error, reason} ->
        Logger.error("[ExJsonSchema] health check failed: #{reason}")
        %{status: "error", reason: reason}
    end
  end
end

Register the plug:

# lib/my_app_web/endpoint.ex
plug MyAppWeb.Plugs.HealthCheck
plug MyAppWeb.Router

Test it:

mix phx.server
curl http://localhost:4000/health | jq .
# {
#   "status": "ok",
#   "json_schema": {
#     "status": "ok",
#     "probe_latency_ms": 1
#   }
# }

Step 3: Track validation error rates in your health response

Expose how many schema validation failures have occurred in the current hour — a spike indicates upstream callers are sending malformed requests:

defmodule MyApp.ValidationMetrics do
  use GenServer

  @table :validation_metrics

  def start_link(_), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)

  def init(_) do
    :ets.new(@table, [:named_table, :public, :set])
    :ets.insert(@table, {:failures, 0})
    :ets.insert(@table, {:successes, 0})
    {:ok, %{}}
  end

  def record_success do
    :ets.update_counter(@table, :successes, 1)
  end

  def record_failure do
    :ets.update_counter(@table, :failures, 1)
  end

  def stats do
    [{_, failures}] = :ets.lookup(@table, :failures)
    [{_, successes}] = :ets.lookup(@table, :successes)
    %{validation_failures: failures, validation_successes: successes}
  end
end

Add metrics calls around your validation logic:

def validate_user(params) do
  case ExJsonSchema.Validator.validate(user_schema(), params) do
    :ok ->
      MyApp.ValidationMetrics.record_success()
      {:ok, params}

    {:error, errors} ->
      MyApp.ValidationMetrics.record_failure()
      {:error, format_errors(errors)}
  end
end

Expose in your health check:

defp run_checks do
  schema = schema_check()
  metrics = MyApp.ValidationMetrics.stats()

  # Alert if failure rate is high
  failure_status =
    if metrics.validation_failures > 100,
      do: "degraded",
      else: "ok"

  overall = schema.status == "ok" and failure_status == "ok"

  %{
    status: if(overall, do: "ok", else: "degraded"),
    json_schema: schema,
    validation_metrics: Map.put(metrics, :status, failure_status)
  }
end

Step 4: Monitor the health endpoint with Vigilmon

  1. Sign up at vigilmon.online
  2. Click New Monitor → HTTP
  3. Enter https://yourdomain.com/health
  4. Set check interval: 1 minute
  5. Enable keyword check: body must contain "status":"ok"
  6. Save

The keyword check catches degraded states from validation error spikes or schema resolution failures, which return HTTP 200 with "status":"degraded".


Step 5: Heartbeat monitoring for schema reload workers

If your schemas are loaded from remote sources (OpenAPI specs, S3, a schema registry), a heartbeat confirms the reload job is running:

# lib/my_app/workers/schema_reload_worker.ex
defmodule MyApp.Workers.SchemaReloadWorker do
  use GenServer
  require Logger

  @interval :timer.minutes(15)

  def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)

  def init(state) do
    schedule()
    {:ok, state}
  end

  def handle_info(:reload, state) do
    reload_schemas()
    schedule()
    {:noreply, state}
  end

  defp reload_schemas do
    spec_url = Application.get_env(:my_app, :openapi_spec_url)

    case Req.get(spec_url, receive_timeout: 10_000) do
      {:ok, %{status: 200, body: spec}} ->
        # Resolve each schema component from the spec
        schemas =
          spec
          |> get_in(["components", "schemas"])
          |> Enum.map(fn {name, schema} ->
            {name, ExJsonSchema.Schema.resolve(schema)}
          end)
          |> Map.new()

        :persistent_term.put(:app_schemas, schemas)
        Logger.info("[SchemaReloadWorker] reloaded #{map_size(schemas)} schemas")
        ping_vigilmon()

      {:error, reason} ->
        Logger.error("[SchemaReloadWorker] failed to reload schemas: #{inspect(reason)}")
    end
  end

  defp ping_vigilmon do
    url = Application.get_env(:my_app, :vigilmon)[:heartbeat_url]

    if url do
      case Req.get(url, receive_timeout: 5_000) do
        {:ok, %{status: 200}} -> :ok
        err -> Logger.warning("[SchemaReloadWorker] heartbeat ping failed: #{inspect(err)}")
      end
    end
  end

  defp schedule, do: Process.send_after(self(), :reload, @interval)
end

Add to your supervision tree:

# lib/my_app/application.ex
children = [
  MyApp.Repo,
  MyApp.ValidationMetrics,
  MyAppWeb.Endpoint,
  MyApp.Workers.SchemaReloadWorker
]

In Vigilmon, create a Heartbeat Monitor:

  1. Click New Monitor → Heartbeat
  2. Set expected interval: 30 minutes
  3. Copy the ping URL
  4. Set it as VIGILMON_HEARTBEAT_URL in your environment

If schema reloads stop (because the spec source is unreachable), your app will use stale schemas — Vigilmon alerts you before stale validation rules reject valid requests.


Step 6: Slack alerts and status page

Slack alerts:

  1. In Vigilmon, go to Notifications → New Channel → Slack
  2. Paste your Slack incoming webhook URL
  3. Enable the channel on your health monitor and schema reload heartbeat

When schema validation health degrades:

🔴 DOWN: yourdomain.com/health
Keyword "status":"ok" not found
Detected: EU-West
1 minute ago

When the schema reload heartbeat misses:

🔴 HEARTBEAT MISSED: Schema Reload Worker
Expected ping within 30 minutes — none received
Last seen: 34 minutes ago

Status page:

  1. Go to Status Pages → New Status Page
  2. Add your HTTP monitor and heartbeat monitor
  3. Share with your API consumers so they know when validation behaviour may be stale

What you've built

| What | How | |------|-----| | Schema probe | Known-good payload validated against probe schema | | Uptime monitoring | Vigilmon HTTP monitor → /health | | Keyword check | Vigilmon keyword match on "status":"ok" | | Validation error rate | ETS counters exposed in health response | | Schema reload monitoring | Heartbeat GenServer + Vigilmon heartbeat monitor | | Slack alerts | Vigilmon Slack notification channel | | Status page | Vigilmon public status page |

ExJsonSchema enforces your API contracts at the boundary. Vigilmon tells you when that enforcement is degraded — or when the schemas enforcing those contracts have stopped refreshing.


Next steps

  • Expose per-schema failure rates in your health response to pinpoint which endpoint is receiving malformed payloads
  • Track validation error messages to identify systematic mismatches (e.g. a client library sending the wrong format for a specific field)
  • Use Vigilmon's response time history to catch gradual increases in schema resolution latency, which can indicate growing schema complexity or $ref resolution depth

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 →