# Job lifecycle logging

> Log every job transition — including the silent unique-lock discards.

Source: https://boring-observability.dev/skyline/docs/job-lifecycle-logging
Section: Observability — Skyline for Laravel documentation
Updated: 2026-07-13

---

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:

```php
// config/horizon.php
'log_channel' => env('HORIZON_LOG_CHANNEL'),
```

```ini
# .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 and `WithoutOverlapping` drops.
- `info` — the above, plus releases, migrations and retries.
- `debug` — everything, including a pending / reserved / completed line for every successful job.

> **Mind the volume at debug**
>
> 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:

```text
[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).

> **To distinguish overlap drops from manual releases**
>
> Import Skyline's drop-in middleware instead of the framework's — it is otherwise identical:
>
> ```php
> 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](https://boring-observability.dev/skyline/docs/unique-job-locks).


## Common questions

### How do I log Laravel queue job lifecycle events?

Point log_channel in config/horizon.php at the channel that should receive the lines (for example HORIZON_LOG_CHANNEL=queue), or leave it unset to use the application default channel. Every line is tagged with the job id, so a single job's whole history is one grep away.

### How do I control the volume of job lifecycle logs?

The channel's own log level is the volume dial, because each event is emitted at a level matching its operational severity: error is terminal failures only; warning adds timeouts and the silent drops; info adds releases, migrations and retries; debug logs a line for every successful job too. At debug a healthy queue writes three lines per job, so use it to trace a problem rather than as a steady state.

### Why was my job released back to the queue?

Skyline attributes it: the job.released event carries a reason of exception, without_overlapping, or released (a manual $job->release()). To distinguish overlap drops from manual releases, import Skyline's drop-in Laravel\Horizon\Middleware\WithoutOverlapping instead of the framework's — it is otherwise identical.
