Observability
Job lifecycle logging
Log every job transition — including the silent unique-lock discards.
A queued job passes through half a dozen states, and Laravel logs almost none of them. Some transitions are not even
observable — a ShouldBeUnique dispatch that is discarded because the lock is held emits no event at all.
Skyline surfaces the whole lifecycle to your logs, with every line tagged by job id so a single job's history is one
grep away.
Enabling
Point log_channel at the channel that should receive the lines:
// config/horizon.php
'log_channel' => env('HORIZON_LOG_CHANNEL'),
# .env
HORIZON_LOG_CHANNEL=queue
Leave it unset to use the application's default channel. If the configured channel name does not resolve, Skyline falls back to the default channel rather than throwing out of a queue listener — a logging misconfiguration should never take down job processing.
Log level is the volume dial
Every event is emitted at a level chosen to match its operational severity, so you tune coverage purely by setting the level on the channel — no separate on/off switches.
error— terminal failures only.warning— failures, timeouts, and the silent drops: unique-lock discards andWithoutOverlappingdrops.info— the above, plus releases, migrations and retries.debug— everything, including a pending / reserved / completed line for every successful job.
At debug, a healthy queue writes three lines per job. On a queue doing 120,000 jobs a day that is
360,000 lines a day. Use debug to trace a problem, not as a steady state.
The events
| Event | Level | When |
|---|---|---|
job.pending |
debug | Job was queued. Notes the delay, when delayed. |
job.reserved |
debug | A worker picked the job up and began processing. |
job.completed |
debug | Job finished successfully. |
job.migrated |
info | A delayed job became available and moved onto the ready queue. |
job.released |
info warning if the reason is an exception |
Job went back onto the queue, with the reason and the delay before it runs again. |
job.retried |
info | A failed job was retried, recording the new job's id. |
job.timed_out |
warning | Job exceeded its timeout, noting whether it will be retried or was marked failed. |
job.overlap_dropped |
warning | Job was dropped without releasing because a WithoutOverlapping lock was held and the middleware is set to dontRelease(). |
unique_lock.discarded |
warning | A dispatch was skipped because a ShouldBeUnique lock was already held. The job was never queued. |
job.failed |
error | Job failed terminally and will not be retried. |
Lines read like this, with the job id in the message so a plain text search finds it:
[job:8813] queued onto [default] (delayed 30s)
[job:8813] migrated to [default] and is ready to run.
[job:8813] reserved from [default] and started processing.
[job:8813] released back to [default] (reason=exception, delay=15s)
[job:8813] failed on [default] and will not be retried (RuntimeException).
Structured context
Every line also carries a structured context array, which is what you actually query once the lines are in a log
pipeline. All events include event, job_id, job, connection and
queue. Beyond that:
| Event | Additional context |
|---|---|
job.released |
reason, delay |
job.timed_out |
will_retry |
job.failed |
exception, message |
job.retried |
retry_id |
unique_lock.discarded |
unique_id, unique_lock_key, job_hash |
job.overlap_dropped |
overlap_lock_key |
Why a job was released
"Released back to the queue" is the single most ambiguous state in a Laravel queue: it can mean the job threw, that
it hit an overlap lock, or that it called $job->release() itself. Skyline attributes it, so
job.released carries a reason of exception,
without_overlapping, or released (a manual release).
Import Skyline's drop-in middleware instead of the framework's — it is otherwise identical:
use Laravel\Horizon\Middleware\WithoutOverlapping;
Without it, a job released by an overlap lock is reported with the generic released reason, and
job.overlap_dropped is never emitted.
Unique-lock discard caveat
Discard logging works by instrumenting the lock the framework takes at dispatch, and it covers the
application's default cache store. A job that overrides uniqueVia() to lock on a
different store is not covered — there is no framework event for the silent drop, so there is nothing to hook. The
same applies if you have replaced the default cache repository with a custom subclass.
For the closely related problem of a lock that is never released, see Unique job locks.