Monitor Cron Jobs and Scheduled Tasks in Elixir with 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:
- Supervisor restart loops — a GenServer that crashes on every tick gets restarted by the supervisor, but if it keeps crashing before reaching the ping, the task never completes. The supervisor reports the process as running. The work is not done.
- Lost timers after restart — when a GenServer crashes and restarts, the
Process.send_aftertimer from the previous incarnation is gone. Ifinit/1does not schedule the first tick, the periodic task never runs again. - Silent Oban failures — an Oban worker that returns
{:ok, _}after processing zero records is technically successful. No error, no retry, no alert. Just empty work every cycle. - Quantum config drift — a hot code upgrade or config change removes a cron entry. Quantum does not alert you when a job disappears from the schedule. The function just stops being called.
- Node disconnections in clusters — in a distributed Elixir cluster, the node running Quantum or Oban might lose its connection. The job moves nowhere. No node picks it up until the cluster heals.
- Database connection pool exhaustion — your Ecto repo pool runs dry. The job waits for a checkout that never comes, times out, and Oban marks it as a timeout. But the cron plugin requeues it, and it times out again. Infinitely.
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:
- Cronitor — roughly $2 per monitor per month on the Developer plan. 40 monitors = $80/month. Enterprise features like teams and SSO push the price higher.
- CronPeek Starter — $9/month flat for up to 50 monitors. That is the same 40 monitors for $9 instead of $80. Over 8x cheaper.
- CronPeek Pro — $29/month for unlimited monitors. If you run 100+ scheduled tasks across a cluster of Elixir nodes, CronPeek Pro is 6x cheaper than Cronitor's equivalent tier.
- CronPeek Free — 5 monitors, no credit card. Perfect for a side project or a single Phoenix app with a handful of Quantum jobs.
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
- One monitor per task — never share a CronPeek monitor ID across different workers or GenServers. You need to know exactly which job failed.
- Use ping_async for hot paths — in Oban workers and Broadway batchers, use
MyApp.CronPeek.ping_async/1so the HTTP request does not add latency to your job pipeline. - Schedule the next tick before doing work — in GenServer
handle_info/2, callProcess.send_afterat the top, not the bottom. If the work crashes, the timer survives the restart. - Report failures explicitly — do not just skip the ping on error. Call
ping_fail/1for an immediate alert instead of waiting for a timeout. This reduces your mean time to detection from minutes to seconds. - Set grace periods generously — if your Quantum job runs every 10 minutes, configure CronPeek to expect a ping every 13–15 minutes. This absorbs slow database queries, brief network hiccups, and node restarts.
- Add the CronPeek module to your supervision tree — if you want connection pooling for pings, start a Finch pool under your supervisor and use Req with that pool. This avoids spawning a new connection for every ping.
- Monitor across cluster nodes — in a multi-node Elixir cluster, ensure only one node runs each Quantum job (Quantum handles this with its cluster mode). Each node's jobs should ping distinct monitor IDs so you can identify which node stopped.
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 FreePutting It All Together
Here is the complete flow for a production Elixir application with CronPeek monitoring across all scheduler types:
- Add the dependency — add
{:req, "~> 0.5"}to yourmix.exsand runmix deps.get. - Create the CronPeek module — copy the
MyApp.CronPeekmodule from the Quick Start section above. Customize the base URL if you are self-hosting. - Create monitors on CronPeek — go to cronpeek.web.app, create a monitor for each scheduled task, and note the monitor IDs.
- Instrument your jobs — add
MyApp.CronPeek.ping/1orping_async/1to each GenServer tick, Quantum job, Oban worker, and Broadway batcher. - Configure grace periods — set each monitor's grace period to 1.3–1.5x the expected interval.
- Set up alert channels — connect email, Slack, PagerDuty, or a custom webhook in the CronPeek dashboard.
- 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.