Alerting
Enable via QUEUE_INSIGHTS_ALERTS_ENABLED=true. Nine detectors run every snapshot tick (≈ every minute) against live Redis state:
| Rule | Scope | Fires when |
|---|---|---|
depth | per-queue | live:depth ≥ a configured threshold |
stalled | per-queue | depth ≥ min_depth AND no worker pickups in idle_seconds |
oldest_pending | per-queue | the oldest runnable pending job has been waiting seconds (skips not-yet-due delayed jobs) |
stuck_inflight | per-queue | the longest-running in-flight job has been executing seconds |
failure_rate | per-class | failed / (processed + failed) ≥ ratio over the current hour bucket AND total ≥ min_jobs |
slow_p95 | per-class | per-class p95 duration ≥ class_threshold_ms[$class] (opt-in per class) |
snapshot_errored | per-queue | the snapshot driver threw on the most recent tick (auto-clears on next success / 10-min TTL) |
backlog_growing | per-queue | least-squares depth slope over the recent samples ≥ min_slope_per_minute (opt-in, warms up after min_samples samples) |
connection_drift | global | pending rows present under a Laravel queue connection that isn't the configured canonical for that queue (opt-in, default off — see Connection aliasing) |
A dashboard-only watchdog (snapshot_command_dead) renders a top-level red banner when live:depth keys are absent for every configured queue — i.e. the snapshot command itself has been silent for ≥ 90 s.
Cooldown applies to outbound notifications only (key: alert:cooldown:{rule}:{c}:{q}, TTL cooldown_seconds). The dashboard always reflects live state.
Alert on individual job failures (job_failed)
The nine rules above are poll-driven. job_failed is a tenth, event-driven rule (opt-in, default off): it fires once on a job's final failure (Laravel's JobFailed — i.e. retries exhausted), the same trigger as spatie/laravel-failed-job-monitor — so you don't need both. On top of a bare per-failure ping it adds per-class cooldown, silencing (queue-insights.silenced), and the same multi-channel routing (Slack / mail / Sentry / log) as every other rule. Because the only signal is the event, it works on any queue driver — no Redis snapshot required.
'job_failed' => ['enabled' => true, 'severity' => 'warning', 'notify' => true],A typed SanderMuller\QueueInsights\Events\JobFailedAlert event is dispatched (cooldown-gated, silencing-filtered) carrying the job class, connection, queue, uuid, and the live exception — subscribe to forward it anywhere.
vs failure_rate: pick job_failed for "tell me about every failure", failure_rate for "tell me when a class is failing a lot" (a ratio over the hour bucket). They're complementary; enabling both gives an alert per incident and a trend alert.
IMPORTANT
Unlike the poll-driven rules (which notify from the snapshot command), job_failed notifies synchronously inside the worker. With Slack/mail/Sentry enabled, the first failure of each class per cooldown window blocks the worker on that network call. For high-failure-volume apps, set 'notify' => false to keep the JobFailedAlert event firing while skipping the package's synchronous channels, and dispatch your own queued notification from a listener.
Config example
// config/queue-insights.php
'alerts' => [
'enabled' => env('QUEUE_INSIGHTS_ALERTS_ENABLED', false),
'cooldown_seconds' => 900,
'rules' => [
'depth' => [
'enabled' => true,
// Multiple thresholds matching the same (connection, queue) →
// highest matching severity wins per tick.
'thresholds' => [
['connection' => 'sqs', 'queue' => 'work', 'depth' => 1000, 'severity' => 'warning'],
['connection' => 'sqs', 'queue' => 'work', 'depth' => 5000, 'severity' => 'critical'],
],
],
'stalled' => ['enabled' => true, 'idle_seconds' => 120, 'min_depth' => 1, 'severity' => 'critical'],
'oldest_pending' => ['enabled' => true, 'seconds' => 600, 'severity' => 'warning'],
'stuck_inflight' => ['enabled' => true, 'seconds' => 300, 'severity' => 'warning'],
'failure_rate' => ['enabled' => true, 'min_jobs' => 20, 'ratio' => 0.10, 'severity' => 'warning'],
// Per-job failure alert (event-driven, opt-in). See "Alert on
// individual job failures" below. `notify => false` keeps the
// JobFailedAlert event but skips this rule's package channels.
'job_failed' => ['enabled' => false, 'severity' => 'warning', 'notify' => true],
'slow_p95' => [
'enabled' => false,
'class_threshold_ms' => ['App\\Jobs\\GenerateReport' => 30_000],
'severity' => 'warning',
],
'snapshot_errored' => ['enabled' => true, 'severity' => 'warning'],
'backlog_growing' => [
'enabled' => false,
'min_slope_per_minute' => 50.0,
'min_samples' => 5,
'severity' => 'warning',
],
'connection_drift' => ['enabled' => false, 'severity' => 'warning'],
],
'channels' => [
'log' => ['enabled' => true, 'level' => 'warning'],
'slack' => ['enabled' => false, 'webhook_url' => env('QUEUE_INSIGHTS_SLACK_WEBHOOK')],
'mail' => ['enabled' => false, 'to' => ['ops@example.com']],
'sentry' => ['enabled' => false],
],
],Heads up —
oldest_pending/stuck_inflightneed pending tracking. Both detectors readpending-zset:*/inflight-zset:*populated by theRecordJobQueued/RecordJobProcessinglisteners. Withpending.enabled = falsethey short-circuit at runtime and a one-off boot warning lists which rules were tripped. Either re-enable pending tracking or disable those rules.
Notification channels
The package ships four channels out of the box:
log— zero-dep, on by default; one structured log line per issue at the configured level (alerts.channels.log.level).slack—Http::postto a Slack-compatible incoming webhook (works with Slack, Mattermost, Rocket.Chat). Block Kit payload with severity-coloured attachment; falls back to plaintextif the receiver rejects Block Kit. SetQUEUE_INSIGHTS_SLACK_WEBHOOKandalerts.channels.slack.enabled = true.QUEUE_INSIGHTS_SLACK_CHANNEL(queue alerts) andQUEUE_INSIGHTS_SCHEDULER_SLACK_CHANNEL(scheduler alerts) are optional display labels surfaced in the dashboard's alert-rules panel — they don't override the webhook's destination, since Slack incoming-webhooks bind the channel server-side at creation time.mail— uses Laravel's first-party mail channel; subject prefix[Queue Insights] {severity}: {rule} on {target}. Recipients fromalerts.channels.mail.to(array of addresses).sentry— captures each issue into your application's existing Sentry project as a grouped event. No DSN config here: the channel uses whatever Sentry hub the host has initialised. Recommended setup issentry/sentry-laravelwithSENTRY_LARAVEL_DSNset (any initialisedsentry/sentryhub works too); then setalerts.channels.sentry.enabled = true. Severity maps fixed —critical → error,warning → warning— and events fingerprint per[queue-insights, rule, target]so Sentry groups one issue per rule+target instead of opening a new one each snapshot tick. Tags (queue_insights.rule/severity/connection/queue/job_class) and the full issue context (as aqueue-insightscontext block) ride along.
slack, mail, and sentry feature-detect their underlying dependency (Illuminate\Http\Client\Factory, mail.manager, and — for sentry — a bound Sentry hub client, not merely the loaded SDK) — if it's missing the channel is silently skipped, and the dashboard's alert-rules panel shows the reason (sentry's row reads SDK not installed when the package is absent, or hub not configured when the SDK is present but no DSN/hub is initialised). Because sentry requires a live client, a misconfigured scheduler-sentry-only setup falls back to the queue-side channels rather than dropping the alert.
Adding more channels (Discord, Teams, PagerDuty, Telegram, …)
The package emits a SanderMuller\QueueInsights\Alerts\Notifications\QueueAlertNotification and routes it through SanderMuller\QueueInsights\Alerts\Notifications\QueueInsightsNotifiable, exactly as Spatie's alerting packages and Horizon do. To add a destination:
Install the matching
laravel-notification-channels/*package (discord,microsoft-teams,pagerduty,telegram,vonage, …).Extend
QueueAlertNotificationto add the channel tovia()and ato{Channel}()method, OR overrideQueueInsightsNotifiableand addrouteNotificationFor{Channel}().Bind your override in your
AppServiceProvider:php$this->app->bind(QueueAlertNotification::class, MyQueueAlertNotification::class); $this->app->bind(QueueInsightsNotifiable::class, MyNotifiable::class);
Typed events (always fire)
Each rule fires a typed event regardless of which channels are enabled — host apps can hook Event::listen(...) for custom routing:
QueueDepthExceeded(existing — added trailing nullable?string $severity)QueueStalled,OldestPendingAging,StuckInFlight,SnapshotErroredJobClassFailureRateExceeded,JobClassP95ExceededBacklogGrowing
Active-rules panel
The dashboard footer renders a read-only summary of alerts.rules + alerts.channels so operators can verify what's monitored without SSH'ing into the server. Edit the config file to change anything — there is no runtime mutation surface.
Migrating from the 0.x alerts.thresholds shape
The pre-1.0 config exposed a single flat alerts.thresholds list. It is still honoured (legacy wins over alerts.rules.depth.thresholds) and emits a one-off boot warning. To migrate:
'alerts' => [
'enabled' => true,
'cooldown_seconds' => 900,
- 'thresholds' => [
- ['connection' => 'sqs', 'queue' => 'work', 'depth' => 1000],
- ],
+ 'rules' => [
+ 'depth' => [
+ 'enabled' => true,
+ 'thresholds' => [
+ ['connection' => 'sqs', 'queue' => 'work', 'depth' => 1000, 'severity' => 'warning'],
+ ],
+ ],
+ ],
],Note: Laravel's mergeConfigFrom is a shallow merge, so hosts that published config/queue-insights.php before this version will not pick up the new nested defaults under alerts.rules.* automatically — copy the new keys from the package config when migrating.
Silencing noisy jobs
Mirrors Horizon's horizon.silenced knob: list job-class FQCNs whose failures should be suppressed from the dashboard's Failed list, the headline failed-tile, the throughput sparkline's failed series, the failure_rate alert detector, and outbound notifications.
'silenced' => [
App\Jobs\IntermittentlyFailingJob::class,
App\Jobs\ThirdPartyApiSometimesFlakes::class,
],
// Glob fallback for whole namespaces or related classes. Exact `silenced`
// entries are matched first; `silenced_patterns` is `Str::is`-style and
// matches case-insensitively, same as `silenced`.
'silenced_patterns' => [
'App\\Jobs\\Reports\\*',
'App\\Jobs\\*Sync',
],Counter writes (qi:processed:{class}:{bucket}, qi:failed:{class}:{bucket}, qi:classes) are preserved — silencing is a read-side filter only, so removing a class from the list immediately re-surfaces its history without any backfill. The class rows table keeps showing throughput / p95 / max for silenced classes with a muted silenced badge so you can still triage them.
| Surface | Behaviour under silencing |
|---|---|
| Failed list (Failed tab) | Hidden by default. The "Show silenced" checkbox on the failed-pane filter form reveals them; URL-shareable as ?fs=1. |
Headline failed_past_hour + throughput sparkline failed series | Silenced classes excluded. Processed series stays exact. |
failure_rate alert detector | Returns null for silenced classes — no event, no notification, no cooldown burned. |
slow_p95 alert detector | Unchanged — silencing is a failure-noise filter, not a perf filter. Exclude noisy classes from class_threshold_ms if you want their perf alerts muted too. |
| Class rows table | Row stays, marked with a muted silenced badge inline next to the FQCN. Operators still see throughput / p95 / max for silenced classes. |
| Modal-by-uuid + chain-lineage click-through + batch-detail items | NOT filtered. Silencing is a list-level filter; uuid-addressed lookups always resolve so a batched member or chain parent stays clickable. |
qi:failed:{class}:{bucket} Redis counters + qi:classes zset | Still written by the listeners. Silencing is reversible without losing history. |
The bulk-retry uuid collector inherits the same SQL exclusion path — bulk-retry actions on the default-filter view never queue silenced classes for retry. Toggle "Show silenced" first if you want them in the bulk set.
Horizon-silenced jobs
When laravel/horizon is installed, entries from Horizon's own config('horizon.silenced') are automatically merged into the same filter set — operator-edited config/horizon.php entries and upstream packages writing to it at boot (e.g. spatie/laravel-health's silence_health_queue_job flag, which adds Spatie\Health\Jobs\HealthQueueJob) take effect without a duplicate queue-insights.silenced entry. Merge is read-only; we never write back to Horizon's config.