tutorial

How to Monitor Decimal with Vigilmon

Decimal is the standard arbitrary-precision decimal arithmetic library for Elixir, essential for handling monetary values without floating-point errors. Learn how to monitor services that depend on Decimal, detect precision failures, and alert on financial calculation health using Vigilmon.

Decimal is the de facto standard Elixir library for arbitrary-precision decimal arithmetic. When you're handling money — processing payments, computing tax, splitting bills, or converting currencies — floating-point math with Float is disqualifying. A Float addition like 0.1 + 0.2 yields 0.30000000000000004; the same addition with Decimal.add("0.1", "0.2") yields 0.3 exactly. Financial services built on Decimal depend on the correctness of every computation, on the availability of exchange rate APIs they call, and on the health of the accounting records they write. Vigilmon adds external uptime monitoring, heartbeat checks, and alerting so the infrastructure surrounding your Decimal-based financial computations stays healthy and any degradation surfaces before it affects customer balances.

What You'll Set Up

  • HTTP health endpoint confirming your pricing and calculation service is reachable
  • Heartbeat monitor for scheduled financial reconciliation jobs
  • Exchange rate API uptime monitor to detect when your currency data source goes dark
  • SSL monitoring for your payment provider endpoints
  • Slack alerts when any financial service layer degrades

Prerequisites

  • Elixir 1.14+ with decimal in mix.exs
  • A Phoenix or OTP application processing monetary values with Decimal
  • A free Vigilmon account

Step 1: Verify Your Decimal Configuration

Decimal uses a configurable context that sets precision and rounding mode. Financial applications should pin these at startup to prevent precision drift:

# lib/my_app/application.ex
defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    # Set a global Decimal context with 28-digit precision and banker's rounding
    Decimal.Context.set(%Decimal.Context{
      precision: 28,
      rounding: :half_even,  # Banker's rounding — standard for financial calculations
      flags: [],
      traps: [:invalid_operation, :division_by_zero]
    })

    children = [
      MyApp.Repo,
      MyAppWeb.Endpoint,
      MyApp.ExchangeRateCache,
      # ...
    ]

    Supervisor.start_link(children, strategy: :one_for_one)
  end
end

Confirm the context is applied in a test:

# test/my_app/decimal_context_test.exs
defmodule MyApp.DecimalContextTest do
  use ExUnit.Case

  test "global context has financial precision" do
    ctx = Decimal.Context.get()
    assert ctx.precision == 28
    assert ctx.rounding == :half_even
  end

  test "monetary addition is exact" do
    result = Decimal.add("0.1", "0.2")
    assert Decimal.equal?(result, Decimal.new("0.3"))
  end

  test "tax calculation rounds correctly" do
    # 7.5% tax on $10.00 = $0.75
    price = Decimal.new("10.00")
    rate = Decimal.new("0.075")
    tax = Decimal.mult(price, rate)
    assert Decimal.equal?(Decimal.round(tax, 2), Decimal.new("0.75"))
  end
end

Step 2: Add a Health Endpoint for Your Pricing Service

# lib/my_app_web/controllers/health_controller.ex
defmodule MyAppWeb.HealthController do
  use MyAppWeb, :controller

  def index(conn, _params) do
    checks = %{
      decimal_context: check_decimal_context(),
      exchange_rate_cache: check_exchange_rate_cache(),
      database: check_database(),
      calculation_smoke_test: check_calculation()
    }

    status = if Enum.all?(checks, fn {_, v} -> v == :ok end), do: 200, else: 503

    conn
    |> put_status(status)
    |> json(%{
      status: if(status == 200, do: "ok", else: "degraded"),
      checks: checks
    })
  end

  defp check_decimal_context do
    ctx = Decimal.Context.get()

    if ctx.precision >= 20 and ctx.rounding == :half_even do
      :ok
    else
      :misconfigured
    end
  end

  defp check_exchange_rate_cache do
    case MyApp.ExchangeRateCache.get("USD", "EUR") do
      {:ok, rate} when is_struct(rate, Decimal) -> :ok
      _ -> :stale_or_unavailable
    end
  end

  defp check_database do
    case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", []) do
      {:ok, _} -> :ok
      _ -> :error
    end
  end

  defp check_calculation do
    # Smoke-test a known calculation with an exact expected result
    price = Decimal.new("99.99")
    discount = Decimal.new("0.15")
    discounted = Decimal.mult(price, Decimal.sub(Decimal.new("1"), discount))
    expected = Decimal.new("84.9915")

    if Decimal.equal?(discounted, expected), do: :ok, else: :calculation_mismatch
  end
end

Register the route:

# lib/my_app_web/router.ex
scope "/", MyAppWeb do
  get "/health", HealthController, :index
end

Test locally:

curl -s http://localhost:4000/health | jq .
# {
#   "status": "ok",
#   "checks": {
#     "decimal_context": "ok",
#     "exchange_rate_cache": "ok",
#     "database": "ok",
#     "calculation_smoke_test": "ok"
#   }
# }

Step 3: Cache Exchange Rate Data with a GenServer

Decimal calculations are only as reliable as the exchange rate data they operate on. Cache rates and surface cache health to Vigilmon:

# lib/my_app/exchange_rate_cache.ex
defmodule MyApp.ExchangeRateCache do
  use GenServer

  require Logger

  @refresh_interval_ms :timer.minutes(15)

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

  def get(from_currency, to_currency) do
    key = {from_currency, to_currency}
    GenServer.call(__MODULE__, {:get, key})
  end

  @impl GenServer
  def init(_) do
    send(self(), :refresh)
    {:ok, %{rates: %{}, last_updated: nil}}
  end

  @impl GenServer
  def handle_call({:get, {from, to}}, _from, state) do
    case Map.get(state.rates, {from, to}) do
      nil -> {:reply, {:error, :not_found}, state}
      rate -> {:reply, {:ok, rate}, state}
    end
  end

  @impl GenServer
  def handle_info(:refresh, state) do
    new_state = case fetch_rates() do
      {:ok, rates} ->
        %{state | rates: rates, last_updated: DateTime.utc_now()}

      {:error, reason} ->
        Logger.error("Exchange rate refresh failed: #{inspect(reason)}")
        state
    end

    Process.send_after(self(), :refresh, @refresh_interval_ms)
    {:noreply, new_state}
  end

  defp fetch_rates do
    # Replace with your exchange rate provider
    case Req.get("https://api.exchangerate-api.com/v4/latest/USD", receive_timeout: 10_000) do
      {:ok, %{status: 200, body: %{"rates" => rates}}} ->
        decimal_rates =
          Map.new(rates, fn {currency, rate} ->
            {{"USD", currency}, Decimal.from_float(rate)}
          end)
        {:ok, decimal_rates}

      {:ok, %{status: status}} ->
        {:error, {:http_error, status}}

      {:error, reason} ->
        {:error, reason}
    end
  end
end

Step 4: Set Up HTTP Monitoring in Vigilmon

  1. Sign in at vigilmon.online
  2. Click New Monitor → HTTP
  3. URL: https://yourdomain.com/health
  4. Expected status: 200
  5. Add a keyword check: "decimal_context":"ok" to confirm the global Decimal context is properly configured
  6. Interval: 1 minute
  7. Save

Add a second keyword check for "calculation_smoke_test":"ok" on the same monitor — this catches precision regressions where the Decimal library behaves unexpectedly after a dependency upgrade.

Monitor your exchange rate API source:

  1. Click New Monitor → HTTP
  2. URL: https://api.exchangerate-api.com/v4/latest/USD (or your provider's URL)
  3. Expected status: 200
  4. Add a keyword check: "rates" to confirm the response contains rate data
  5. Interval: 5 minutes
  6. Save

If the exchange rate API goes dark, your cache serves stale rates. Vigilmon alerts you before the staleness becomes a customer-impacting problem.


Step 5: Heartbeat Monitoring for Reconciliation Jobs

Financial applications typically run nightly reconciliation to verify that computed totals match ledger entries. Add a heartbeat so Vigilmon detects when reconciliation stops running:

# lib/my_app/workers/reconciliation_worker.ex
defmodule MyApp.Workers.ReconciliationWorker do
  use Oban.Worker, queue: :reconciliation, max_attempts: 1

  require Logger

  @impl Oban.Worker
  def perform(%Oban.Job{}) do
    date = Date.utc_today() |> Date.add(-1)

    case MyApp.Accounting.reconcile_day(date) do
      {:ok, %{discrepancies: 0}} ->
        ping_heartbeat()
        :ok

      {:ok, %{discrepancies: count}} ->
        Logger.error("Reconciliation for #{date}: #{count} discrepancies found")
        # Don't ping heartbeat — alert Vigilmon by not pinging
        {:error, :discrepancies_found}

      {:error, reason} ->
        Logger.error("Reconciliation failed for #{date}: #{inspect(reason)}")
        {:error, reason}
    end
  end

  defp ping_heartbeat do
    url = Application.get_env(:my_app, :vigilmon)[:reconciliation_heartbeat_url]

    if url do
      case Req.get(url, receive_timeout: 5_000) do
        {:ok, _} -> :ok
        {:error, reason} ->
          Logger.warning("Vigilmon heartbeat ping failed: #{inspect(reason)}")
      end
    end
  end
end

In Vigilmon:

  1. Click New Monitor → Heartbeat
  2. Name: nightly-reconciliation
  3. Expected interval: 24 hours
  4. Copy the ping URL and set it as VIGILMON_RECONCILIATION_HEARTBEAT_URL

Note: the heartbeat is intentionally not pinged when discrepancies are found — Vigilmon's missed heartbeat alert then acts as both a "reconciliation didn't run" alert and a "reconciliation found problems" alert.


Step 6: SSL Monitoring for Payment Provider Endpoints

Decimal powers your internal calculations, but the endpoints your application calls to submit those calculations — payment gateways, banking APIs, fraud detection services — all need SSL monitoring:

  1. New Monitor → SSL Certificatehttps://api.stripe.com
  2. New Monitor → SSL Certificatehttps://api.paypal.com
  3. New Monitor → SSL Certificatehttps://yourdomain.com (your own service)
  4. Alert threshold: 14 days before expiry

Step 7: Alert on Financial Service Degradation via Slack

  1. In Vigilmon: Notifications → New Channel → Slack
  2. Paste your Slack incoming webhook URL
  3. Enable it on all monitors: /health, exchange rate API, reconciliation heartbeat, SSL monitors

Use separate Slack channels for different severity levels — exchange rate API failures and SSL expiry warrant a different channel (or on-call page) than normal service health:

#monitoring-alerts (health check failures, heartbeat misses)
#monitoring-critical (SSL expiry, reconciliation discrepancies)

What You've Built

| What | How | |------|-----| | Decimal context verification | Health check confirming 28-digit precision and banker's rounding | | Calculation smoke test | Health endpoint that verifies a known Decimal computation | | Exchange rate cache health | GenServer freshness check in /health | | Exchange rate API monitoring | HTTP monitor with keyword check on provider API | | Reconciliation monitoring | Heartbeat only pinged on clean reconciliation, not on discrepancies | | Payment provider SSL | Certificate expiry alerts for all financial endpoints |

Decimal guarantees correctness at the arithmetic level. Vigilmon guarantees the services, data sources, and scheduled jobs that surround your financial calculations stay healthy.


Next Steps

  • Add a Vigilmon response-time monitor on your /health endpoint — latency growth in financial APIs often precedes availability failures
  • Use Vigilmon's status page to communicate planned maintenance windows for financial services to customers
  • Add a keyword check for "exchange_rate_cache":"ok" on your /health monitor separately from the Decimal context check, so you can distinguish the two failure modes in alerts
  • Consider adding a synthetic transaction test as a heartbeat: run a full pricing calculation end-to-end and ping only on exact expected output

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 →