renoki-co/horizon-exporter
A Prometheus exporter for Laravel Horizon that exposes queue, job, and worker metrics from Horizon's Redis data via a metrics endpoint, making it easy to monitor Horizon in Grafana and alert on queue health.
renoki-co/horizon-exporter exposes Laravel Horizon's Redis-backed metrics as a Prometheus scrape endpoint. Version 2.0.0 targets Laravel 9+ only — Laravel 8.x support was dropped in this release, so an existing Laravel 8 app cannot take this upgrade without also moving the framework to 9 (PHP >= 8.0).
Minimal path from zero to a scraped metric:
composer require renoki-co/horizon-exporter:^2.0 — on a Laravel 8 project, upgrade the framework first, then the package.php artisan vendor:publish --provider="RenokiCo\HorizonExporter\HorizonExporterServiceProvider"
The published config/horizon-exporter.php controls whether the endpoint is enabled, the URI path, and the middleware stack applied to it.curl -s http://localhost:8000/horizon-exporter/metrics | head -50. Do this before writing a single dashboard — the exact metric names and label sets (queue, connection, supervisor-ish labels depending on version) are what you will bind PromQL and alert rules to, and they are the one thing worth copy-pasting from your own response rather than from any blog post.prometheus.yml:
- job_name: 'laravel-horizon'
metrics_path: /horizon-exporter/metrics
scrape_interval: 60s
scrape_timeout: 10s
static_configs:
- targets: ['app.example.com']
If you are on Kubernetes with the Prometheus Operator, this is usually a ServiceMonitor/PodMonitor against the app Service rather than a static target.First real use case: one Grafana panel plotting queue workload and wait time per queue, plus an alert on wait time. That is ~90% of the value; everything else is refinement.
Prerequisite: Horizon must actually be installed, configured, and running against the same Redis connection the exporter reads from. Without Horizon's queue configuration there is nothing meaningful to export.
Treat the exporter as a metrics adapter, not a monitoring system. Horizon still owns the sampling; the exporter only publishes it. The typical production shape is: Horizon runs on its own host/container → exporter endpoint on that same app → Prometheus scrapes it → Grafana/Alertmanager consume it.
Gate the route by environment. Keep it off in local/staging where Horizon isn't supervised:
// config/horizon-exporter.php
'enabled' => env('HORIZON_EXPORTER_ENABLED', false),
and set the env var only on the Horizon-running production instances. In practice you only need to scrape the node(s) where horizon workers are supervised — not every web pod.
Protect the endpoint rather than leaving it open. The route is registered by the package; use the config's middleware to attach auth-style or IP-allowlist middleware, or terminate access at the ingress/nginx level (allow 10.0.0.0/8; deny all;). Prometheus' own basic_auth/bearer_token scrape config pairs with a middleware entry here.
Alert on wait time, not on job counts alone. Queue length is noisy and normal; sustained wait time is the signal that actually means "users are waiting":
max by (queue) (laravel_horizon_queue_wait_time) > 30
(substitute the metric name your /metrics output actually emits; run the alert for for: 5m so a single bursty deploy doesn't page anyone).
Pair throughput against queue length to distinguish "backlog growing because producers are fast" from "workers are dead". A flat throughput with rising queue length is the classic stalled-worker signature and worth its own alert.
Multi-environment / multi-cluster: add an env or cluster external label at the Prometheus scrape-config level rather than trying to get it out of the exporter — it keeps dashboards portable across clusters.
Ship the dashboard as code. Store the Grafana JSON (or a Jsonnet/grafonnet source) next to the app. The metric names are version-coupled to the exporter, so a dashboard committed at the same time as the ^2.0 bump is the cheap way to survive the next major.
If your app exposes other Prometheus endpoints already, keep this route alongside them and use one scrape job per endpoint or a shared metrics_path convention — don't try to merge exporters into one path.
2.0.0 supports Laravel 9 only. Because composer.json will now constrain illuminate/* to ^9.0, an accidental composer update on a Laravel 8 app will fail resolution (usually a good failure, but check it doesn't silently downgrade the package back to 1.x and leave you reading docs for the wrong version).illuminate/support constraint first — the release notes mention nothing beyond v9, so pin the framework and validate in a branch before rolling the whole app forward.route:cache and config:cache will bite you. After changing the path or middleware in horizon-exporter.php, run php artisan config:clear && php artisan route:clear (or re-cache) — otherwise you'll debug an unchanged endpoint for twenty minutes.scrape_timeout you can get context deadline exceeded scrapes and gaps in Grafana. Either raise scrape_timeout or trim horizon.php's supervised queue list.instance labels; aggregate with max by (queue) in PromQL, or restrict the scrape target to Horizon hosts only./horizon when numbers look wrong./horizon./metrics route with promphp/prometheus_client_php and keep this package for the Horizon-native gauges.How can I help you explore Laravel packages today?