tutorial

How to Monitor Ecto.Multi with Vigilmon

Track multi-step Ecto transactions in production — detect rollback spikes, slow atomic operations, and step-level failures before they corrupt your data or stall critical workflows.

How to Monitor Ecto.Multi with Vigilmon

Ecto.Multi lets you group multiple database operations into a single atomic transaction and then run them with a single Repo.transaction/2 call. Each step is named; if any step fails, all previous steps roll back automatically and the failing step's name is returned alongside the error. Teams use it for multi-table writes — creating a user plus their organization plus a billing record in one commit — where partial success is worse than no success at all.

When an Ecto.Multi pipeline fails, the failure is usually silent from an infrastructure perspective: the process handled the request, the database rolled back cleanly, and HTTP status might still be 200 if the caller catches the error gracefully. What you lose is visibility into which step failed, how often, and whether failure rates are trending up. Vigilmon heartbeats and HTTP monitors close that gap.


Why Monitor Ecto.Multi?

Multi-step transactions are some of the most complex operations in an Elixir application. Failures compound:

  • Silent rollbacks — the database rolls back cleanly but the caller swallows the error; the operation simply doesn't happen with no visible side effect
  • Step-level blind spots — without instrumentation you know a transaction failed but not which of the five steps caused it
  • Timeout cascades — a slow third step holds the database connection open, starving the connection pool for subsequent requests
  • Idempotency violations — retrying a failed Ecto.Multi that partially executed external side effects (email sends, Stripe charges) before the rollback causes duplicate actions
  • Migration-triggered regressions — a schema change breaks an Ecto.Multi step that was passing before; the regression only shows up in load

Key Metrics to Track

| Metric | What it tells you | |--------|-------------------| | Multi transaction error rate | How often {:error, step, changeset, changes} is returned | | Failing step name | Which named step in the multi is the source of failures | | Transaction duration | Whether multi pipelines are taking longer than expected | | Rollback count by step | Relative frequency of rollback-triggering steps | | Connection pool wait | Whether long multi transactions are starving the pool | | Heartbeat freshness | Whether critical multi-backed workflows ran recently |


Step 1: Instrument Ecto.Multi with Telemetry

Ecto emits [:ecto, :repo, :query] telemetry events for individual queries. For multi-level instrumentation, wrap your transaction calls in a helper that reports outcomes:

# lib/my_app/repo_multi.ex
defmodule MyApp.RepoMulti do
  require Logger

  @doc """
  Runs an Ecto.Multi and emits telemetry on success or failure.
  Returns the same result tuple as Repo.transaction/2.
  """
  def run(multi, name) do
    start = System.monotonic_time(:millisecond)

    result = MyApp.Repo.transaction(multi)

    duration_ms = System.monotonic_time(:millisecond) - start

    case result do
      {:ok, _changes} ->
        :telemetry.execute(
          [:my_app, :multi, :success],
          %{duration_ms: duration_ms},
          %{multi_name: name}
        )

      {:error, failed_step, changeset, _changes} ->
        Logger.warning(
          "[Multi:#{name}] step #{failed_step} failed: #{inspect(changeset)}",
          multi_name: name,
          failed_step: failed_step
        )

        :telemetry.execute(
          [:my_app, :multi, :failure],
          %{duration_ms: duration_ms},
          %{multi_name: name, failed_step: failed_step}
        )
    end

    result
  end
end

Attach a telemetry handler to count failures:

# lib/my_app/application.ex
def start(_type, _args) do
  :telemetry.attach_many(
    "my-app-multi-handler",
    [
      [:my_app, :multi, :success],
      [:my_app, :multi, :failure],
    ],
    &MyApp.MultiTelemetry.handle_event/4,
    nil
  )

  # ... rest of start/2
end
# lib/my_app/multi_telemetry.ex
defmodule MyApp.MultiTelemetry do
  require Logger

  def handle_event([:my_app, :multi, :success], %{duration_ms: ms}, %{multi_name: name}, _) do
    Logger.info("[Multi:#{name}] completed in #{ms}ms")
  end

  def handle_event([:my_app, :multi, :failure], %{duration_ms: ms}, meta, _) do
    Logger.error(
      "[Multi:#{meta.multi_name}] FAILED at step #{meta.failed_step} after #{ms}ms"
    )
  end
end

Now every multi transaction logs its outcome with structured metadata.


Step 2: Expose a Health Endpoint That Tests a Representative Multi

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

  def check(conn, _params) do
    checks = %{
      database: check_database(),
      multi_pipeline: check_multi_pipeline()
    }

    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_database do
    case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", []) do
      {:ok, _} -> :ok
      {:error, _} -> :error
    end
  end

  defp check_multi_pipeline do
    # Run a non-destructive multi that exercises the same tables as your critical path
    multi =
      Ecto.Multi.new()
      |> Ecto.Multi.run(:ping, fn repo, _changes ->
        case Ecto.Adapters.SQL.query(repo, "SELECT 1", []) do
          {:ok, _} -> {:ok, :pong}
          error -> {:error, error}
        end
      end)

    case MyApp.Repo.transaction(multi) do
      {:ok, _} -> :ok
      _ -> :error
    end
  end
end

Add the route:

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

Step 3: Add a Heartbeat for Critical Multi Workflows

For business-critical multi pipelines (user registration, order placement, account provisioning), wrap the success path with a Vigilmon heartbeat ping:

# lib/my_app/accounts.ex
defmodule MyApp.Accounts do
  require Logger

  def register_user(attrs) do
    multi =
      Ecto.Multi.new()
      |> Ecto.Multi.insert(:user, User.changeset(%User{}, attrs))
      |> Ecto.Multi.insert(:profile, fn %{user: user} ->
        Profile.changeset(%Profile{}, %{user_id: user.id})
      end)
      |> Ecto.Multi.run(:welcome_email, fn _repo, %{user: user} ->
        MyApp.Mailer.send_welcome(user)
      end)

    case MyApp.RepoMulti.run(multi, :user_registration) do
      {:ok, changes} ->
        ping_heartbeat(:user_registration)
        {:ok, changes}

      error ->
        error
    end
  end

  defp ping_heartbeat(name) do
    url = Application.get_env(:my_app, :vigilmon_heartbeats)[name]

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

Configure heartbeat URLs:

# config/runtime.exs
config :my_app, :vigilmon_heartbeats,
  user_registration: System.get_env("VIGILMON_HEARTBEAT_USER_REGISTRATION"),
  order_placement: System.get_env("VIGILMON_HEARTBEAT_ORDER_PLACEMENT")

Step 4: Set Up Monitoring in Vigilmon

HTTP Monitor (endpoint health)

  1. Sign in at vigilmon.online
  2. Click New Monitor → HTTP
  3. URL: https://yourdomain.com/health
  4. Expected status: 200
  5. Check interval: 1 minute

Heartbeat Monitor (critical multi workflows)

For each critical multi pipeline:

  1. Click New Monitor → Heartbeat
  2. Name: User Registration Pipeline
  3. Expected interval: 1 hour (or match your expected call frequency)
  4. Copy the URL → set as VIGILMON_HEARTBEAT_USER_REGISTRATION in your runtime config

If your registration flow stops completing multi transactions successfully — because a step is failing, a changeset is invalid, or a migration broke a dependency — the heartbeat goes silent and Vigilmon alerts you.


Step 5: Alerting

In Vigilmon, go to Notifications → New Channel → Slack and configure your webhook.

Health endpoint down:

🔴 DOWN: yourdomain.com/health
Status: 503 Service Unavailable
Check: multi_pipeline returned error

Heartbeat missed:

🔴 MISSED: User Registration Pipeline
Last successful multi: 2 hours ago
Action: Check logs for Ecto.Multi failure at step :user or :profile

Set a 15-minute grace period on heartbeat monitors to absorb temporary load spikes that briefly suppress successful transactions.


What You Built

| What | How | |------|-----| | Multi instrumentation | Telemetry events on success/failure with step name | | Structured logging | Logger.warning with multi_name and failed_step metadata | | Health endpoint | /health runs a non-destructive multi ping | | HTTP monitor | Vigilmon checks /health every minute | | Critical workflow heartbeats | Vigilmon heartbeat per business-critical multi pipeline | | Slack alerting | Vigilmon Slack notification channel |

Ecto.Multi keeps your data consistent. Vigilmon keeps you informed when that consistency guarantee is silently failing.


Next Steps

  • Export [:my_app, :multi, :failure] telemetry to your APM (AppSignal, Datadog) for failure-rate dashboards by step name
  • Add a Vigilmon response-time check on /health to catch slow transaction duration before it causes pool exhaustion
  • Create separate heartbeat monitors for each critical business workflow so you know exactly which pipeline degraded
  • Use Vigilmon's incident history to correlate multi failure spikes with deployments or schema migrations

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 →