Job lifecycle logging
Log every job transition — including the silent unique-lock discards.
Skyline can log every transition a queued job makes — queued, reserved, released, migrated,
retried, timed out, completed, failed — to a log channel you choose, with each line tagged by job id so one job's
whole history is a single grep. It also logs the two transitions nothing else reports: a dispatch
discarded because a ShouldBeUnique lock was held, and a job dropped by a
WithoutOverlapping lock.
That last part is the reason the feature exists. A queued job passes through half a dozen states and Laravel logs almost none of them — and some transitions are not observable at all, because the framework emits no event for them.
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.