← All heartbeat examples · What Heartbeat is
Heartbeat from your project's code
A cron line proves the host is up. A ping from inside your own process proves the thing you actually care
about is running: the loop is looping, the scheduler is scheduling, the event loop is not stalled. The whole
integration is one HTTP GET to the slot's URL from
Settings → Heartbeats, kept in an environment variable called
HEARTBEAT_URL. No library, no token, no response to parse.
1. Where the ping goes decides what it proves
- At the end of each unit of work in a loop (a poll, a batch, a consumed message): proves the loop is processing. A process that is alive but stuck on one item goes silent, which is exactly what you want to hear about.
- In a job scheduled every minute: proves the scheduler is alive and a worker for that queue picked the job up. This is the right form for a job system; it has its own page, background workers and job queues.
- On a timer inside a long-running server: proves the process is up and its event loop is turning. It says nothing about whether requests succeed; pair it with a request check if you need that.
- Not inside a request handler. A ping on every request proves that someone made a request. A quiet night becomes a false alarm, and a dead worker behind a busy web tier is never noticed.
2. Four rules
- Ping after success, never before.
work(); heartbeat()turns a crashing job into silence; the other order hides it. - Never let the ping raise into the work. Catch everything, log a warning, move on. Monitoring that can take down the thing it monitors is worse than none.
- Unset means off. Read the URL from the environment and return at once when it is empty. Development, test and CI then never ping production's slot, and a CI run can never mask a dead production process.
- Short timeouts. Five seconds to connect, ten in total. A slow network must cost the work a few seconds at most, never a hang.
3. A helper in each language
Each is a few lines you paste once and call from wherever the previous section put it.
Ruby / Rails
A module, a one-line job, and a Solid Queue recurring entry that runs it every minute. The job form is the one that also proves your workers are alive.
# app/lib/heartbeat.rb
require "net/http"
module Heartbeat
def self.ping
url = ENV["HEARTBEAT_URL"]
return if url.blank?
uri = URI(url)
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
http.get(uri.path)
end
rescue StandardError => e
Rails.logger.warn("heartbeat ping failed: #{e.class}: #{e.message}")
end
end
# app/jobs/heartbeat_job.rb
class HeartbeatJob < ApplicationJob
def perform = Heartbeat.ping
end
# config/recurring.yml
production:
heartbeat:
class: HeartbeatJob
schedule: "* * * * *"
Python
Standard library only. Shown at the end of a worker loop; the same function goes at the end of a Celery task or a cron-driven script.
import os, urllib.request, logging
HEARTBEAT_URL = os.environ.get("HEARTBEAT_URL")
def heartbeat():
if not HEARTBEAT_URL:
return
try:
urllib.request.urlopen(HEARTBEAT_URL, timeout=10).close()
except Exception as e: # never let monitoring break the work
logging.warning("heartbeat ping failed: %s", e)
while True:
process_one_batch()
heartbeat()
Node.js
Built-in fetch (Node 18+) on an interval inside a
server process. The ping stops the moment the process dies or the event loop stalls on something synchronous.
const HEARTBEAT_URL = process.env.HEARTBEAT_URL;
async function heartbeat() {
if (!HEARTBEAT_URL) return;
try {
await fetch(HEARTBEAT_URL, { signal: AbortSignal.timeout(10_000) });
} catch (err) {
console.warn("heartbeat ping failed:", err.message);
}
}
setInterval(heartbeat, 60_000);
heartbeat();
Go
A ticker in its own goroutine, or the call at the bottom of your worker's
for loop.
package main
import (
"io"
"log"
"net/http"
"os"
"time"
)
var heartbeatClient = &http.Client{Timeout: 10 * time.Second}
func heartbeat() {
url := os.Getenv("HEARTBEAT_URL")
if url == "" {
return
}
resp, err := heartbeatClient.Get(url)
if err != nil {
log.Printf("heartbeat ping failed: %v", err)
return
}
io.Copy(io.Discard, resp.Body) // read to EOF so the connection can be reused
resp.Body.Close()
}
func main() {
go func() {
for range time.Tick(time.Minute) {
heartbeat()
}
}()
serve() // your server or worker loop
}
PHP / Laravel
For a plain site, call it from a cron-driven script. In Laravel the scheduler already runs every minute,
so the ping proves schedule:run is wired.
file_get_contents on a URL needs
allow_url_fopen enabled (the default) and the
openssl extension for https; with either missing it
returns false and the @ hides the warning, so check
the record once after wiring it up.
<?php
function heartbeat(): void {
$url = getenv("HEARTBEAT_URL");
if (!$url) return;
$ctx = stream_context_create(["http" => ["timeout" => 10]]);
@file_get_contents($url, false, $ctx);
}
// routes/console.php (Laravel 11+)
use Illuminate\Support\Facades\Schedule;
Schedule::call(fn () => heartbeat())->everyMinute();
4. Several copies of the same process
Three replicas pinging one slot mean "at least one replica is alive", and nothing more; two can die
unnoticed. That is often the guarantee you want for a web tier, so take it on purpose and say so in the
slot's description. When each copy matters (one worker per region, one agent per customer), give each its
own slot and its own HEARTBEAT_URL from your
orchestrator, with the instance name in the slot's label so the Heartbeats page reads like an inventory.
Staging and production never share a slot either: a healthy staging process pinging the production slot is the one failure mode that hides a dead production process completely.
5. Check the wiring once
Switch the slot on, deploy, and open the slot's note. Within about a minute of the first ping the
checker writes ON|…; if nothing appears after
three minutes, the first silence email tells you the variable did not reach the process. Then stop the
process on purpose and wait: the report should arrive within three minutes, and the next start writes a
fresh ON. A monitor you have never seen fire is a
hope, not a monitor.
In your test suite leave HEARTBEAT_URL unset, so
the helper is a no-op; if you want to assert it is called, stub the HTTP client rather than hitting the
real URL. The report format and the JSON webhook are described under
what you get back.