Laravel Horizon in Prometheus: Why Exported Throughput Reads Low
· 13 min read · Boring Observability
Verified against Laravel 13 · Horizon 5.x
Horizon's dashboard is good at one question: what is happening on the queues right now. It is less good at the
questions that come after an incident. What did the backlog look like at 03:10? Did failures on webhooks
start before or after the deploy? Should somebody have been paged? Those answers live in Prometheus, next to your
database and host metrics, and Horizon has no exporter of its own.
The obvious fix is to export the figures the dashboard already shows, and the graphs that produces look right and
aren't. This post explains why: where Horizon keeps its numbers, why they don't survive
rate(), what the existing exporter packages read, and a small counter-based exporter you can drop into
any Laravel app today. The PromQL and alert rules at the end work with it as written.
Key takeaways#
- Horizon's throughput is not a counter. It lives in a Redis hash that
horizon:snapshotdeletes every time it runs, so anything Prometheus scrapes from it resets every five minutes and loses the jobs processed between the last scrape and the snapshot. - Failures are not in Horizon's metrics at all. The metrics listener returns early for failed jobs. A per-queue failure rate has to be counted separately.
- Horizon's "wait" is an estimate of time to clear the backlog (pending jobs × average runtime ÷ processes), not a measurement of how long anything waited. The number worth alerting on is the age of the oldest pending job.
- Count from queue events, store in Redis, render on scrape. Workers are separate PHP processes with nothing long-lived to hold a counter, so they write to Redis and a route reads it.
JobAttemptedandJobFailedare the right events;JobProcessedfires for jobs that released themselves or were failed before running. - Scrape one target. The exporter reads shared Redis, so every web node returns the same fleet-wide figures, and scraping all of them multiplies every
sum()by your node count.
Where Horizon keeps its numbers#
Every successful job updates two Redis hashes, one for its queue (queue:emails) and one for its class
(job:App\Jobs\SendInvoice). Each hash holds two fields: throughput, a count, and
runtime, a running average in milliseconds. The update is a Lua script fired from Horizon's
UpdateJobMetrics listener on JobDeleted, and the first thing that listener does is return if
the job has failed. Failed jobs appear in the dashboard's failed list, never in its metrics.
horizon:snapshot is what turns those hashes into the graphs. For every measured queue and class it reads
both fields and deletes the hash in one transaction, then appends the values to a sorted set trimmed to the last 24
entries. The hashes start again from zero. At the five-minute schedule the Horizon docs recommend, the dashboard's graphs
cover about two hours.
Scheduling it more often doesn't change that. The command takes a lock for
horizon.metrics.snapshot_lock minus 30 seconds, which is 270 seconds at the default of 300, and never
releases it. An everyMinute() schedule still produces roughly one snapshot every five minutes. The other
four runs find the lock held and do nothing.
The per-queue wait time on the dashboard, and the LongWaitDetected notification built on it, come from
WaitTimeCalculator: the number of ready jobs multiplied by the queue's average runtime, divided by the
processes serving it. It answers "how long would this backlog take to clear at today's pace". A single job stuck
at the head of a queue for an hour, with no workers on it, has a small backlog and a small estimate.
What rate() does with a number that resets every five minutes#
Prometheus counters are allowed to reset. A process restarts, its counter drops to zero, and rate() sees
the drop, assumes the series restarted from zero, and carries on. So exporting the throughput field as a
counter looks like it should work, because the snapshot is just a reset.
The problem is what happens between the last scrape before a snapshot and the snapshot itself. Jobs processed in that
gap were counted into the hash, and the hash was deleted before any scrape saw them. rate() can't recover
increments it never observed. With a 30-second scrape interval the gap is anywhere from zero to thirty seconds, fifteen
on average, out of every five-minute window.
| Scrape interval | Average throughput lost | Worst case |
|---|---|---|
| 15s | 2.5% | 5% |
| 30s | 5% | 10% |
| 60s | 10% | 20% |
The loss is systematic, so the graph is smooth and consistently low. Nobody notices a throughput panel that reads 8%
under. The runtime field has a milder version of the same issue: it
is the average of jobs since the last snapshot, so each window's first minute is an average of a handful of jobs and
jumps around before settling.
What the Horizon exporter packages read#
The two packages most people find are spatie/laravel-prometheus, a general Laravel exporter with a set of Horizon collectors, and the older lkaemmerling/laravel-horizon-prometheus-exporter. Their Horizon metrics are near-identical: master supervisor count and status, workload and processes per queue, a jobs-per-minute figure, a failed-jobs figure and recent jobs. All of them are gauges read from Horizon's repositories at scrape time.
The gauges that read live state are fine. Queue length, process counts and Horizon status are current-value questions, and that is what a gauge is for. The two that describe a rate are where it goes wrong.
-
Jobs per minute calls
MetricsRepository::jobsProcessedPerMinute(), which divides the throughput since the last snapshot by the minutes since the last snapshot, with a floor of one minute. For the first minute after every snapshot the divisor is pinned at 1 while the count starts from zero, so the figure drops to near zero and climbs back. Graphed, that is a notch every five minutes. And ifhorizon:snapshotisn't scheduled, reading the figure writeslast_snapshot_aton the first scrape and the hashes are never reset again, so the gauge becomes a lifetime average that gets flatter every day. -
Failed jobs (named
horizon_failed_jobs_per_hourin the Spatie collector) callscountRecentlyFailed(), which counts therecent_failed_jobsset. Horizon keeps that set fortrim.recent_failedminutes, and the default is 10080, so the figure counts the past week.
Two smaller things are worth checking in either package. Workload rows are labelled with the supervisor's queue
string, so queues balanced together arrive as one series named high,default. And both packages' IP
allowlists default to empty, which both treat as "allow everyone". Set it before the route is deployed.
A counter exporter in two files#
The fix is to stop reading Horizon's windowed figures and count outcomes yourself, into a store that never resets. The PHP-specific wrinkle is where the counter lives. A Go service keeps its counters in memory and serves them from the same process. A Laravel app has dozens of short-lived worker processes and a separate pool of web processes, none of which can see another's memory. So workers write to Redis, and the scrape endpoint is an ordinary web request that reads it.
First, the counting. Register two listeners in a service provider's boot():
use App\Support\QueueMetrics;
use Illuminate\Queue\Events\JobAttempted;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Event;
Event::listen(JobAttempted::class, function (JobAttempted $event) {
$outcome = match (true) {
$event->job->isReleased() => 'retried',
$event->successful() => 'processed',
default => null, // failures are counted from JobFailed, below
};
if ($outcome !== null) {
QueueMetrics::record($outcome, $event->job);
}
});
Event::listen(JobFailed::class, function (JobFailed $event) {
QueueMetrics::record('failed', $event->job);
});
namespace App\Support;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Support\Facades\Redis;
class QueueMetrics
{
public const KEY = 'queue-metrics:counters';
public static function record(string $outcome, Job $job): void
{
Redis::pipeline(function ($pipe) use ($outcome, $job) {
$pipe->hincrby(self::KEY, "queue|{$outcome}|{$job->getQueue()}", 1);
$pipe->hincrby(self::KEY, "job|{$outcome}|{$job->resolveName()}", 1);
});
}
}
The choice of events is the part that is easy to get wrong. Queue::after(), the listener most examples
reach for, listens to JobProcessed, and the worker raises that whenever fire() returns
without throwing. A job released by RateLimited or WithoutOverlapping returns without
throwing, so it counts as processed. So does a job the worker failed for exceeding its attempts before it ran, which
also raises JobFailed and is counted twice. JobAttempted fires once per attempt, in a
finally block, and isReleased() separates the two cases.
Failures come from JobFailed rather than JobAttempted because of timeouts. A job that hits
its timeout is failed from the alarm handler, which then kills the worker process. JobFailed fires before
that; the finally block never runs. The same kill means a timed-out job with attempts left isn't released
at all. It stays reserved until retry_after expires and is picked up again, and the timed-out attempt
lands in none of the three counters. The timeout vs
retry_after post covers that path in detail.
Then the endpoint. It renders the counters, adds two gauges per queue, and emits the text exposition format by hand, which is short enough that a client library isn't worth the dependency:
use App\Support\QueueMetrics;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Redis;
use Laravel\Horizon\Contracts\MasterSupervisorRepository;
use Laravel\Horizon\Contracts\SupervisorRepository;
use Symfony\Component\HttpFoundation\IpUtils;
Route::get('/queue-metrics', function () {
abort_unless(IpUtils::checkIp(request()->ip(), ['127.0.0.1', '::1', '10.0.0.0/8']), 403);
$series = [];
$labels = ['queue' => 'queue', 'job' => 'job_class'];
// Counters: written by every worker, never reset.
foreach (Redis::hgetall(QueueMetrics::KEY) as $field => $count) {
[$scope, $outcome, $name] = explode('|', $field, 3);
$series["horizon_{$scope}_{$outcome}_total"][] = [[$labels[$scope] => $name], (int) $count];
}
// Gauges: every queue a running supervisor serves. Keyed by connection and
// queue, because each server runs its own supervisor for the same queues.
foreach (app(SupervisorRepository::class)->all() as $supervisor) {
foreach (array_keys((array) $supervisor->processes) as $pool) {
[$connection, $names] = explode(':', $pool, 2);
foreach (explode(',', $names) as $name) {
$queue = Queue::connection($connection);
$oldest = $queue->creationTimeOfOldestPendingJob($name);
$id = "{$connection}:{$name}";
$key = ['queue' => $name, 'connection' => $connection];
$series['horizon_queue_length'][$id] = [$key, $queue->pendingSize($name)];
$series['horizon_queue_oldest_pending_seconds'][$id] = [$key, $oldest ? now()->timestamp - $oldest : 0];
}
}
}
$masters = app(MasterSupervisorRepository::class)->all();
$series['horizon_up'][] = [[], (int) ! empty($masters)];
$series['horizon_master_supervisors'][] = [[], count($masters)];
$escape = fn ($value) => str_replace(['\\', '"', "\n"], ['\\\\', '\\"', '\n'], (string) $value);
$body = '';
foreach ($series as $metric => $samples) {
$body .= "# TYPE {$metric} ".(str_ends_with($metric, '_total') ? 'counter' : 'gauge')."\n";
foreach ($samples as [$set, $value]) {
$pairs = collect($set)->map(fn ($v, $k) => $k.'="'.$escape($v).'"')->implode(',');
$body .= $metric.($pairs === '' ? '' : '{'.$pairs.'}').' '.$value."\n";
}
}
return response($body, 200, ['Content-Type' => 'text/plain; version=0.0.4']);
});
Three details in there matter. The label is job_class rather than job because
Prometheus reserves job for the scrape config's job_name and renames any exposed one to
exported_job. The escaping is required, not defensive: every job class contains backslashes, and an
unescaped App\Jobs\SendInvoice makes the whole scrape fail to parse. And the IP check is
only as good as request()->ip(). Behind a load balancer, or nginx in front of php-fpm, every request
comes from the proxy until trusted proxies are configured.
creationTimeOfOldestPendingJob() reads the createdAt field Laravel writes into the payload
at dispatch. It survives releases and delays. A job dispatched with a one-hour delay is an hour old the moment it
becomes due, and a job released with a ten-minute backoff comes back ten minutes older. Set backlog alert thresholds
above your longest delay or backoff, or they will fire on work that is on schedule.
What this exporter doesn't have is anything about how long jobs take or how many workers are on them. Runtime is the one to add
next if you need it, and building it yourself means you can do better than Horizon's average. Record the start time in
a JobProcessing listener (same process, so a static array keyed by job id is enough) and increment
histogram buckets in the JobAttempted listener. That gives you a p95, which an average can't.
Scrape one target, not every node#
Every piece of state the exporter reads is in Redis, so any web node answers with the same fleet-wide numbers. If
Prometheus discovers all your web nodes and scrapes each one, every series arrives once per node, distinguished only
by the instance label. sum(rate(...)) then reports three times your real throughput on three
nodes. Point Prometheus at a single address: one node, or the load balancer.
scrape_configs:
- job_name: horizon
metrics_path: /queue-metrics
scrape_interval: 30s
static_configs:
- targets: ['app-internal.example.com']
The PromQL behind the useful panels#
Jobs per minute, by queue, computed from the counter rather than read from Horizon:
sum by (queue) (rate(horizon_queue_processed_total[5m])) * 60
Failure ratio per queue. Note that the denominator counts successful jobs only, so this is failures relative to successes; add the failed rate to the denominator if you want a share of all outcomes:
sum by (queue) (rate(horizon_queue_failed_total[5m]))
/ sum by (queue) (rate(horizon_queue_processed_total[5m]))
Queues with a backlog that nobody is draining. unless rather than and, so a queue that has
never processed a job still matches:
max by (queue) (horizon_queue_length) > 0
unless sum by (queue) (rate(horizon_queue_processed_total[10m])) > 0
Release churn, which usually means a rate limiter or an overlap lock is doing more work than the jobs are:
sum by (job_class) (rate(horizon_job_retried_total[15m]))
/ sum by (job_class) (rate(horizon_job_processed_total[15m]))
Alert rules#
groups:
- name: horizon
rules:
- alert: HorizonDown
expr: max(horizon_up) == 0
for: 2m
- alert: HorizonDuplicateMasters
expr: max(horizon_master_supervisors) > 2 # your server count
for: 10m
- alert: QueueBacklogAgeing
expr: max by (queue) (horizon_queue_oldest_pending_seconds) > 900
for: 5m
- alert: QueueFailureRate
expr: |
sum by (queue) (rate(horizon_queue_failed_total[5m]))
/ sum by (queue) (rate(horizon_queue_processed_total[5m])) > 0.05
for: 10m
QueueBacklogAgeing is the one we would keep if we could only have one. A queue of ten thousand fast jobs
is healthy, and a queue of one job that nobody has picked up in fifteen minutes is not. Queue length can't tell those
apart and oldest-job age can. It has one blind spot: the gauges come from the supervisor list, so when Horizon is
fully down there are no queue series left to alert on. HorizonDown covers that case.
HorizonDuplicateMasters catches the failure from our
supervisor upgrade
postmortem, where a second complete Horizon tree registered itself and kept running. Set the threshold to the
number of servers that should be running Horizon. More masters than servers means one of them is running two.
Putting it in Grafana#
Every rate panel should use $__rate_interval rather than a fixed [5m], so it stays correct
when someone zooms to a seven-day range or you change the scrape interval. Put queue and
job_class in dashboard variables and filter every panel with =~"$queue", and one dashboard
covers every queue instead of needing a copy each.
You don't have to build it. The metric names in the exporter above match the ones Skyline exports, so Skyline's Grafana dashboard imports straight onto it. The throughput, failure and release panels for queues and job classes populate, along with queue length, oldest pending job and Horizon status. The runtime, process and paused-state panels stay empty, because stock Horizon has nothing correct to feed them.
What Skyline exports instead#
We built Skyline's Prometheus endpoint to feed the
panels that stay empty on stock Horizon. It is off
until HORIZON_PROMETHEUS_ENABLED=true, and while off the route isn't registered at all. When on, it
serves /horizon/prometheus to loopback only until you widen the allowlist.
The main difference is the counters. Skyline's horizon:snapshot folds each window it closes into
a running total before resetting it, and a scrape reads that total plus the window still open. Nothing is lost
between scrape and snapshot, and failures and releases are counted per queue and per job class alongside throughput.
The oldest-pending age is measured from Horizon's own job record, which is updated when a job is released or leaves
the delayed set, so a job that has just become due reads as new. Queues balanced as one pool are split into one series
each, with a group label. The dashboard linked above is the same file the package publishes, and all 27
of its panels populate.
Skyline doesn't export runtime histograms either; runtime and wait are moving averages, exported as gauges. If percentiles matter more to you than the rest of this, the DIY route with buckets is the better one.
Most of the events in the listener above come from the life of a Laravel queued job, which walks through when each one fires and why a release puts a job at the back of the queue. For the heavier release churn that the retried counter tends to expose, rate-limited APIs and Laravel queues is the next one to read.
Frequently asked questions
Does Laravel Horizon have a Prometheus exporter?
No. Horizon keeps its metrics in Redis for its own dashboard and exposes no /metrics endpoint. The usual options are spatie/laravel-prometheus, which has a set of Horizon collectors, the older lkaemmerling/laravel-horizon-prometheus-exporter, or a small exporter of your own that counts queue events into Redis and renders them on scrape.
Why does Horizon throughput in Grafana read lower than the real job count?
Horizon's throughput lives in a Redis hash that horizon:snapshot reads and deletes every five minutes. Jobs counted into the hash after the last scrape and before the snapshot are deleted before Prometheus sees them, and rate() cannot recover increments it never observed. With a 30-second scrape interval that loses about 5% of throughput on average and up to 10%.
Why is horizon_failed_jobs_per_hour so high?
It counts Horizon's recent_failed_jobs set, which is kept for trim.recent_failed minutes. The default is 10080, so the figure is failures over the past week rather than the past hour. Failed jobs are also missing from Horizon's throughput metrics entirely, because the metrics listener returns early for them, so a per-queue failure rate has to be counted separately.
Which queue event should I count processed jobs from?
JobAttempted, checking isReleased() and successful(). Queue::after() listens to JobProcessed, which fires whenever the job returns without throwing, so jobs released by RateLimited or WithoutOverlapping count as processed, and jobs failed for exceeding their attempts are counted twice. Count failures from JobFailed, which still fires when a job times out and the worker is killed.
What should I alert on for Laravel queues?
The age of the oldest pending job on each queue. Queue length cannot tell ten thousand fast jobs from one job nobody has picked up in fifteen minutes, and oldest-job age can. Horizon's wait time is not a substitute: it is a forecast of time to clear the backlog, pending jobs times average runtime divided by processes, and stays small for a single stuck job.
Keep reading
11 min read
Instrumenting Guzzle in Laravel: Logging and Metrics for Every Outgoing Request
Log and measure every outgoing HTTP call in Laravel with a Guzzle middleware and on_stats, including...
10 min read
Dispatching Laravel Jobs After the Transaction Commits
Why Laravel jobs dispatched in a transaction can run before it commits, and how afterCommit behaves with...