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
- Sign up at vigilmon.online
- Click New Monitor → HTTP
- Enter
https://yourdomain.com/health - Set check interval: 1 minute
- Enable keyword check: body must contain
"status":"ok" - 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:
- Click New Monitor → Heartbeat
- Set expected interval: 30 minutes
- Copy the ping URL
- Set it as
VIGILMON_HEARTBEAT_URLin 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:
- In Vigilmon, go to Notifications → New Channel → Slack
- Paste your Slack incoming webhook URL
- 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:
- Go to Status Pages → New Status Page
- Add your HTTP monitor and heartbeat monitor
- 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.