Elixir Tutorial

Monitor Cron Jobs and Scheduled Tasks in Elixir with CronPeek

Published March 29, 2026 · 14 min read · By CronPeek

Elixir gives you fault tolerance, supervisors that restart crashed processes, and the BEAM virtual machine that runs millions of lightweight processes without breaking a sweat. What Elixir does not give you is a guarantee that your scheduled work actually completed. A GenServer that restarts after a crash loses its Process.send_after timer. An Oban job that returns {:ok, result} with stale data looks successful from the inside. A Quantum cron entry quietly removed during a config deploy means the job never fires again. The process tree is healthy. The work is not happening.

Dead man's switch monitoring solves this. Your Elixir scheduled task pings an external HTTP endpoint after every successful run. If the ping stops arriving within the expected window, you get an alert. This guide walks through integrating CronPeek heartbeat monitoring with GenServer periodic tasks, Quantum cron schedules, Oban background workers, and Broadway data pipelines — the four most common patterns for scheduled work in Elixir applications.

Why Elixir Applications Need External Cron Monitoring

Supervisors restart crashed processes. That is the promise of OTP. But scheduled task failures are subtler than crashes:

Internal monitoring (Oban Web, Quantum logs, supervisor telemetry) tells you what happened inside the BEAM. External monitoring tells you whether the result actually reached the outside world. You need both.

Quick Start: Ping CronPeek with Req

The simplest integration is a single HTTP GET after your task completes. Add req to your mix.exs dependencies:

defp deps do
  [
    {:req, "~> 0.5"}
  ]
end

Create a lightweight module that handles CronPeek pings throughout your application:

defmodule MyApp.CronPeek do
  @base_url "https://cronpeek.web.app/api/v1/ping"
  @timeout 5_000

  @doc """
  Ping CronPeek after a successful job run.
  Returns :ok on success, logs and returns :error on failure.
  """
  def ping(monitor_id) do
    url = "#{@base_url}/#{monitor_id}"

    case Req.get(url, receive_timeout: @timeout, retry: false) do
      {:ok, %Req.Response{status: status}} when status in 200..299 ->
        :ok

      {:ok, %Req.Response{status: status}} ->
        require Logger
        Logger.warning("CronPeek ping returned #{status} for #{monitor_id}")
        :error

      {:error, reason} ->
        require Logger
        Logger.warning("CronPeek ping failed for #{monitor_id}: #{inspect(reason)}")
        :error
    end
  end

  @doc """
  Report a failure to CronPeek for immediate alerting.
  """
  def ping_fail(monitor_id) do
    url = "#{@base_url}/#{monitor_id}/fail"

    case Req.get(url, receive_timeout: @timeout, retry: false) do
      {:ok, _} -> :ok
      {:error, _} -> :error
    end
  end

  @doc """
  Fire-and-forget ping. Spawns a Task so the caller is never blocked.
  """
  def ping_async(monitor_id) do
    Task.start(fn -> ping(monitor_id) end)
  end
end

Key details: the 5-second timeout prevents a CronPeek outage from blocking your application. The retry: false option ensures Req does not retry failed pings — the next scheduled run will send the next ping. The ping_async/1 function spawns a fire-and-forget Task so the caller returns immediately.

Using HTTPoison Instead of Req

If your project already uses HTTPoison, the same pattern works with a slightly different API:

defmodule MyApp.CronPeek do
  @base_url "https://cronpeek.web.app/api/v1/ping"

  def ping(monitor_id) do
    url = "#{@base_url}/#{monitor_id}"

    case HTTPoison.get(url, [], recv_timeout: 5_000) do
      {:ok, %HTTPoison.Response{status_code: code}} when code in 200..299 ->
        :ok

      {:ok, %HTTPoison.Response{status_code: code}} ->
        require Logger
        Logger.warning("CronPeek returned #{code} for #{monitor_id}")
        :error

      {:error, %HTTPoison.Error{reason: reason}} ->
        require Logger
        Logger.warning("CronPeek ping failed: #{inspect(reason)}")
        :error
    end
  end

  def ping_fail(monitor_id) do
    HTTPoison.get("#{@base_url}/#{monitor_id}/fail", [], recv_timeout: 5_000)
    :ok
  end
end

GenServer Periodic Tasks with Process.send_after

The most common Elixir pattern for scheduled work is a GenServer that sends itself a message on a timer using Process.send_after/3. This is the building block behind many Elixir scheduling libraries. Here is how to wire it up with CronPeek monitoring:

defmodule MyApp.InventorySync do
  use GenServer

  @interval :timer.minutes(10)
  @monitor_id "mon_inventory_elixir_001"

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl true
  def init(_opts) do
    # Schedule the first tick immediately
    send(self(), :tick)
    {:ok, %{last_run: nil, consecutive_failures: 0}}
  end

  @impl true
  def handle_info(:tick, state) do
    # Always schedule the next tick first, so a crash does not lose the timer
    Process.send_after(self(), :tick, @interval)

    state =
      case do_inventory_sync() do
        {:ok, count} ->
          MyApp.CronPeek.ping(@monitor_id)
          %{state | last_run: DateTime.utc_now(), consecutive_failures: 0}

        {:error, reason} ->
          require Logger
          Logger.error("Inventory sync failed: #{inspect(reason)}")
          MyApp.CronPeek.ping_fail(@monitor_id)
          %{state | consecutive_failures: state.consecutive_failures + 1}
      end

    {:noreply, state}
  end

  defp do_inventory_sync do
    # Your actual sync logic here
    # Returns {:ok, records_synced} or {:error, reason}
    {:ok, 42}
  end
end

The critical detail: Process.send_after is called at the top of handle_info/2, before the task logic runs. If the task crashes, the supervisor restarts the GenServer, init/1 schedules a new first tick, and monitoring continues. If you put Process.send_after after the task logic and the task raises an exception, the timer is never set and the periodic task dies permanently — even though the process restarts.

CronPeek receives a heartbeat every 10 minutes. Configure the monitor's grace period to 13–15 minutes, so a single slow run does not trigger a false alert. If CronPeek sees no ping for 15 minutes, your team gets an email, Slack notification, or webhook.

Quantum Scheduler with CronPeek Monitoring

Quantum is Elixir's most popular cron scheduler. It supports standard cron expressions and runs jobs as separate processes. Adding CronPeek monitoring to a Quantum job takes three lines:

# config/runtime.exs
config :my_app, MyApp.Scheduler,
  jobs: [
    # Every 5 minutes: sync pricing data
    {"*/5 * * * *", {MyApp.Jobs.PricingSync, :run, []}},
    # Daily at 3 AM: generate reports
    {"0 3 * * *", {MyApp.Jobs.DailyReport, :run, []}},
    # Hourly: clean expired sessions
    {"0 * * * *", {MyApp.Jobs.SessionCleanup, :run, []}}
  ]
defmodule MyApp.Jobs.PricingSync do
  require Logger

  @monitor_id "mon_pricing_quantum_001"

  def run do
    Logger.info("Starting pricing sync...")

    case MyApp.PricingService.sync_all_providers() do
      {:ok, %{synced: count, errors: 0}} ->
        Logger.info("Pricing sync complete: #{count} providers synced")
        MyApp.CronPeek.ping(@monitor_id)

      {:ok, %{synced: count, errors: errors}} ->
        Logger.warning("Pricing sync partial: #{count} synced, #{errors} errors")
        # Still ping success — partial completion is expected
        MyApp.CronPeek.ping(@monitor_id)

      {:error, reason} ->
        Logger.error("Pricing sync failed: #{inspect(reason)}")
        MyApp.CronPeek.ping_fail(@monitor_id)
    end
  end
end
defmodule MyApp.Jobs.DailyReport do
  require Logger

  @monitor_id "mon_daily_report_001"

  def run do
    Logger.info("Generating daily report...")

    with {:ok, data} <- MyApp.Reports.gather_metrics(),
         {:ok, _report} <- MyApp.Reports.generate_pdf(data),
         {:ok, _} <- MyApp.Reports.send_email(data) do
      Logger.info("Daily report sent successfully")
      MyApp.CronPeek.ping(@monitor_id)
    else
      {:error, reason} ->
        Logger.error("Daily report failed: #{inspect(reason)}")
        MyApp.CronPeek.ping_fail(@monitor_id)
    end
  end
end

Each Quantum job gets its own CronPeek monitor ID. This is essential — when the pricing sync fails but the daily report keeps running, you need to know exactly which job stopped. Configure the CronPeek grace period to match the cron schedule: a 5-minute job should have a 7–8 minute grace period, and the daily report should have a 25-hour window to handle minor schedule drift.

Oban Worker with CronPeek Integration

Oban is the standard for persistent background jobs in Elixir. It stores jobs in PostgreSQL, provides retries, and has a cron plugin for scheduled recurring work. CronPeek adds external verification on top of Oban's internal monitoring:

defmodule MyApp.Workers.DataExport do
  use Oban.Worker,
    queue: :exports,
    max_attempts: 3,
    unique: [period: 300]

  @monitor_id "mon_data_export_oban_001"

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"account_id" => account_id}}) do
    with {:ok, records} <- MyApp.Accounts.fetch_export_data(account_id),
         {:ok, file_url} <- MyApp.Storage.upload_csv(records, account_id),
         {:ok, _} <- MyApp.Notifications.send_export_ready(account_id, file_url) do

      # Ping CronPeek on successful completion
      MyApp.CronPeek.ping_async(@monitor_id)

      {:ok, %{records: length(records), url: file_url}}
    else
      {:error, reason} ->
        # Report failure for immediate alerting
        MyApp.CronPeek.ping_fail(@monitor_id)
        {:error, reason}
    end
  end
end

For Oban cron jobs (scheduled recurring work), configure the plugin in your Oban setup:

# config/config.exs
config :my_app, Oban,
  repo: MyApp.Repo,
  queues: [default: 10, exports: 5, maintenance: 2],
  plugins: [
    {Oban.Plugins.Cron,
     crontab: [
       {"0 */6 * * *", MyApp.Workers.DataExport, args: %{"account_id" => "all"}},
       {"*/15 * * * *", MyApp.Workers.HealthCheck},
       {"0 2 * * *", MyApp.Workers.DatabaseMaintenance}
     ]}
  ]

Why use CronPeek alongside Oban's built-in monitoring? Because Oban tells you what happened inside your application. CronPeek tells you the job ran at all. If your PostgreSQL database goes down, Oban cannot enqueue or track jobs. CronPeek's external dead man's switch catches the absence. If your entire Elixir node crashes and never comes back, Oban has no way to alert you. CronPeek notices the missing ping and fires an alert.

Broadway Pipeline Monitoring

Broadway handles data ingestion pipelines in Elixir — consuming from SQS, RabbitMQ, Kafka, or other message sources. While Broadway is not a traditional cron scheduler, many teams use it for periodic batch processing. If your pipeline stops consuming messages, you need to know:

defmodule MyApp.OrderPipeline do
  use Broadway

  @monitor_id "mon_order_pipeline_001"

  def start_link(_opts) do
    Broadway.start_link(__MODULE__,
      name: __MODULE__,
      producer: [
        module: {BroadwaySQS.Producer,
          queue_url: System.fetch_env!("ORDER_QUEUE_URL"),
          config: [region: "us-east-1"]
        },
        concurrency: 2
      ],
      processors: [
        default: [concurrency: 10]
      ],
      batchers: [
        default: [batch_size: 50, batch_timeout: 1_000, concurrency: 4]
      ]
    )
  end

  @impl true
  def handle_message(_processor, message, _context) do
    message
    |> Broadway.Message.update_data(&process_order/1)
  end

  @impl true
  def handle_batch(:default, messages, _batch_info, _context) do
    # Batch processed successfully — ping CronPeek
    MyApp.CronPeek.ping_async(@monitor_id)
    messages
  end

  @impl true
  def handle_failed(messages, _context) do
    require Logger
    Logger.error("#{length(messages)} orders failed processing")

    if length(messages) > 10 do
      MyApp.CronPeek.ping_fail(@monitor_id)
    end

    messages
  end

  defp process_order(data) do
    # Your order processing logic
    data
  end
end

The handle_batch/4 callback fires after each batch is processed. If your pipeline is consuming steadily, CronPeek receives a ping every few seconds. Configure the CronPeek monitor with a grace period of 10–15 minutes, so an empty queue does not trigger a false alert. If the pipeline truly stalls — dead producer, crashed consumer, exhausted connection pool — the pings stop and you get an alert.

CronPeek vs Cronitor: Pricing Comparison for Elixir Teams

Most Elixir applications run multiple scheduled tasks. A typical Phoenix app might have 5–10 Quantum cron jobs, 10–20 Oban workers, a few GenServer periodic tasks, and a couple of Broadway pipelines. That adds up to 20–40 monitors. Here is how pricing compares:

Both services support the same integration pattern: HTTP GET to a unique ping URL. Switching from Cronitor to CronPeek requires changing a single base URL in your MyApp.CronPeek module. No code logic changes, no library swaps.

Best Practices for Elixir Cron Monitoring

Monitor your Elixir cron jobs in 60 seconds

Free tier includes 5 monitors. No credit card required. Set up a dead man's switch for your GenServer tasks, Quantum schedules, and Oban workers today.

Monitor 5 Cron Jobs Free

Putting It All Together

Here is the complete flow for a production Elixir application with CronPeek monitoring across all scheduler types:

  1. Add the dependency — add {:req, "~> 0.5"} to your mix.exs and run mix deps.get.
  2. Create the CronPeek module — copy the MyApp.CronPeek module from the Quick Start section above. Customize the base URL if you are self-hosting.
  3. Create monitors on CronPeek — go to cronpeek.web.app, create a monitor for each scheduled task, and note the monitor IDs.
  4. Instrument your jobs — add MyApp.CronPeek.ping/1 or ping_async/1 to each GenServer tick, Quantum job, Oban worker, and Broadway batcher.
  5. Configure grace periods — set each monitor's grace period to 1.3–1.5x the expected interval.
  6. Set up alert channels — connect email, Slack, PagerDuty, or a custom webhook in the CronPeek dashboard.
  7. Deploy and verify — deploy your updated code, confirm pings appear in the CronPeek dashboard, and deliberately stop a task to verify the alert fires.

The entire setup takes less than 10 minutes. Your Elixir application gets external dead man's switch monitoring that catches every failure mode that supervisors, health checks, and internal logging miss. Silent crashes, lost timers, stalled pipelines, database outages — if the work stops, CronPeek notices.

Frequently Asked Questions

How do I monitor a GenServer periodic task for silent failures?

After your GenServer handle_info/2 callback completes the scheduled work, send an HTTP GET to your CronPeek ping URL using Req or HTTPoison. Use Process.send_after/3 to reschedule the next tick. If CronPeek stops receiving pings, it triggers an alert via email, Slack, or webhook.

Does CronPeek work with the Quantum scheduler?

Yes. In your Quantum job function, add a CronPeek ping after the task logic completes. Quantum runs jobs as separate processes, so a crash in one job does not affect others. CronPeek catches jobs that crash before reaching the ping, jobs removed from the schedule accidentally, and the Quantum scheduler itself going down.

Can I monitor Oban background jobs with CronPeek?

Yes. In your Oban worker's perform/1 callback, ping CronPeek after the job logic succeeds. Oban provides internal retry and error tracking, but CronPeek adds external dead man's switch monitoring that works even if your entire Oban queue or PostgreSQL database goes down.

How much does Elixir cron monitoring cost with CronPeek vs Cronitor?

CronPeek Starter is $9/month flat for 50 monitors. Cronitor charges roughly $2/monitor/month, so 50 jobs cost $100/month with Cronitor versus $9/month with CronPeek. The free tier includes 5 monitors with no credit card required.

Should I use HTTPoison or Req to ping CronPeek from Elixir?

Either works. HTTPoison is the established HTTP client. Req is newer and more ergonomic, maintained by Elixir core team member Wojtek Mach. For new projects, Req is recommended for its simpler API and fewer dependencies. Both support the fire-and-forget pattern needed for monitoring pings.