Scout APM is PHP application performance monitoring designed for developers. With tracing logic that ties issues back to the line of code causing them, you can pinpoint n+1 queries, memory leaks, and other abnormalities in real time so you can knock them out and get back to building a great product. Start your free 14-day trial today and get the performance insight you need in less than 4 minutes.

Introducing Laravel Schedule Monitor

Original – by Freek Van der Herten – 12 minute read

Since version 5, Laravel has a built-in scheduler to perform tasks at regular intervals. In normal circumstances, these scheduled tasks will run just fine.

Out of the box, Laravel doesn't offer a way to see the status of the scheduled tasks. When did they run, how long did a task run, which tasks did throw an exception?

Laravel Schedule Monitor is a new Spatie package that monitors all schedule tasks in a Laravel app. In this blog post, I'd like to introduce the package to you.

screenshot

Using the package

To monitor your schedule, you should first run schedule-monitor:sync. This command will take a look at your schedule and create an entry for each task in the monitored_scheduled_tasks table.

screenshot

You should probably execute this command in the script that deploys your app to the production environment. This way, your scheduled tasks will always stay in sync with the schedule monitor.

To view all monitored scheduled tasks, you can run schedule-monitor:list. This command will list all monitored scheduled tasks. It will show you when a scheduled task has last started, finished, or failed.

screenshot

The package will write an entry to the monitored_scheduled_task_log_items table in the DB each time a scheduled task starts, end, fails, or is skipped. The log items also hold other interesting metrics like memory usage, execution time, and more.

When the package detects that the last run of a scheduled task did not run in time, the schedule-monitor list will display that task using a red background color. In this screenshot, the task named your-command ran too late.

screenshot

The package will determine that a task ran too late if it was not finished at the time it was supposed to run + the grace time. You can think of the grace time as the number of minutes that a task under normal circumstances needs to finish. By default, the package grants a grace time of 5 minutes to each task.

You can customize the grace time by using the graceTimeInMinutes method on a task. In this example, a grace time of 10 minutes is used for the your-command task.

// in app/Console/Kernel.php

protected function schedule(Schedule $schedule)
{
   $schedule->command('your-command')->daily()->graceTimeInMinutes(10);
}

Getting notified

We assume that, when your scheduled tasks do not run properly, a scheduled task that sends out notifications would probably not run either. That's why this package doesn't send out notifications by itself.

The package comes with built-in support to sync your schedule with the cron check feature of Oh Dear. Each time a scheduled task is executed, the server monitor package will send a "ping" request to Oh Dear. If pings do not arrive in time, Oh Dear will notify you.

After providing an Oh Dear API token and site ID, the sync command will sync your schedule with Oh Dear.

screenshot

The list command will display whether tasks are registered at the service.

screenshot

At the moment of writing, the cron check feature of Oh Dear is in closed beta, but you can request early access.

How it works under the hood

After installing the package, the first thing you need to do is run the sync command. This command will take a look at the schedule. For each task in the schedule, it will write an entry in the monitored_scheduled_tasks table.

Each time a scheduled task is executed, Laravel will fire off a couple of events. The package listens for these events and updates the relevant row in the monitored_scheduled_tasks table.

That's how it works in a nutshell. Now, let's dive into the details.

Syncing the schedule

Here's the syncScheduledTasksWithDatabase function in that sync command.

protected function syncScheduledTasksWithDatabase(ScheduledTasks $scheduledTasks): self
{
    $this->comment('Start syncing schedule with database...');

    $monitoredScheduledTasks = ScheduledTasks::createForSchedule()
        ->uniqueTasks()
        ->map(function (Task $task) {
            return MonitoredScheduledTask::updateOrCreate(
                ['name' => $task->name()],
                [
                    'type' => $task->type(),
                    'cron_expression' => $task->cronExpression(),
                    'grace_time_in_minutes' => $task->graceTimeInMinutes(),
                ]
            );
        });

    MonitoredScheduledTask::query()->whereNotIn('id', $monitoredScheduledTasks->pluck('id'))->delete();

    return $this;
}

Let's take a look at that createForSchedule function.

public static function createForSchedule()
{
    $schedule = app(Schedule::class);

    return new static($schedule);
}

public function __construct(Schedule $schedule)
{
    $this->schedule = $schedule;

    $this->tasks = collect($this->schedule->events())
        ->map(fn (Event $event): Task => ScheduledTaskFactory::createForEvent($event));
}

The code above will get all registered events for the schedule. An "event" in this context is a task that you have scheduled. We're wrapping each $event in a Task class. By wrapping these $events in a Task we can add some extra behavior on for each event on that Task class. We keep all tasks in the $tasks instance variable.

Let's take a look at that ScheduledTasksFactory. This is the entire class.

class ScheduledTaskFactory
{
    public static function createForEvent(Event $task): Task
    {
        $taskClass = collect([
            ClosureTask::class,
            JobTask::class,
            CommandTask::class,
            ShellTask::class,
        ])
            ->first(fn (string $taskClass) => $taskClass::canHandleEvent($task));

        return new $taskClass($task);
    }
}

In this factory, you see that there are four types of scheduled tasks in a Laravel app: a closure, a job, a command, and a shell command.

Here's an example of how you would schedule these types in the scheduler.

// in app/Console/Kernel.php

protected function schedule(Schedule $schedule)
{
    // a closure
    $schedule->call(fn () => 1 + 1)->everyMinute();
    
    // a job
    $schedule->job(new MyJob())->hourly()
    
    // an artisan command
    $schedule->command('an-artisan-command')->daily();
    
    // a shell command
    $schedule->command('ls')->weekly();
}

These types of tasks each need to be handle by the package in a slightly different way. For instance, the name we'll use to track these tasks is determined differently for each type of task.

Let's take a quick look at the ShellTask class.

namespace Spatie\ScheduleMonitor\Support\ScheduledTasks\Tasks;

use Illuminate\Console\Scheduling\CallbackEvent;
use Illuminate\Console\Scheduling\Event;
use Illuminate\Support\Str;

class ShellTask extends Task
{
    public static function canHandleEvent(Event $event): bool
    {
        if ($event instanceof CallbackEvent) {
            return true;
        }

        return true;
    }

    public function defaultName(): ?string
    {
        return Str::limit($this->event->command, 255);
    }

    public function type(): string
    {
        return 'shell';
    }
}

Here you can see that in case of shell tasks, we'll use the command property of the event as the default name.

In the abstract Task class, where ShellTask extends from, you can see that we use that defaultName function inside of the name function.

// in the Task class

public function name(): ?string
{
    return $this->event->monitorName ?? $this->defaultName();
}

You might wonder where that monitorName property is coming from. Let's sidestep and explain that too. I'm assuming that for some tasks, you might want to manually want to specify the name that should be used to track it. For a closure, you are even required to specify a name, because a closure has by its very nature no name.

The Illuminate\Console\Scheduling\Event (which is the class in Laravel that represents a scheduled task) is macroable, so we can add a function of our own to it. In the service provider, there is a macro being registered that allow that monitorName to be set.

// in ScheduledMonitorServiceProvider

Event::macro('monitorName', function (string $monitorName) {
    $this->monitorName = $monitorName;

    return $this;
});

This is how the macro can be used.

// in app/Console/Kernel.php

protected function schedule(Schedule $schedule)
{
   $schedule->call(fn () => 1 + 1)->hourly()->monitorName('addition-closure');
}

Now that you know that we wrap each scheduled task in a Task class and how they can be named, let's take another look at syncScheduledTasksWithDatabase in the sync command. Here's that code again.

protected function syncScheduledTasksWithDatabase(ScheduledTasks $scheduledTasks): self
{
    $this->comment('Start syncing schedule with database...');

    $monitoredScheduledTasks = ScheduledTasks::createForSchedule()
        ->uniqueTasks()
        ->map(function (Task $task) {
            return MonitoredScheduledTask::updateOrCreate(
                ['name' => $task->name()],
                [
                    'type' => $task->type(),
                    'cron_expression' => $task->cronExpression(),
                    'grace_time_in_minutes' => $task->graceTimeInMinutes(),
                ]
            );
        });

    MonitoredScheduledTask::query()->whereNotIn('id', $monitoredScheduledTasks->pluck('id'))->delete();

    return $this;
}

In essence, this code will create a MonitoredScheduledTask model for each Task. We can just call $task->name() because we handled each different case in the underlying specific class (ShellTask, CommandTask, ...).

Tracking scheduled tasks

Laravel will fire off an event for each scheduled task that is starting, has finished, has been skipped, or is failing.

In the ScheduleMonitorServiceProvider we set up an event subscriber that listens for these events.

protected function registerEventSubscriber(): self
{
    Event::subscribe(ScheduledTaskEventSubscriber::class);

    return $this;
}

The ScheduledTaskEventSubscriber is quite small. Here is the code of the entire class.

namespace Spatie\ScheduleMonitor;

// ...

class ScheduledTaskEventSubscriber
{
    public function subscribe(Dispatcher $events): void
    {
        $events->listen(
            ScheduledTaskStarting::class,
            fn (ScheduledTaskStarting $event) => optional(MonitoredScheduledTask::findForTask($event->task))->markAsStarting($event)
        );

        $events->listen(
            ScheduledTaskFinished::class,
            fn (ScheduledTaskFinished $event) => optional(MonitoredScheduledTask::findForTask($event->task))->markAsFinished($event)
        );

        $events->listen(
            ScheduledTaskFailed::class,
            fn (ScheduledTaskFailed $event) => optional(MonitoredScheduledTask::findForTask($event->task))->markAsFailed($event)
        );

        $events->listen(
            ScheduledTaskSkipped::class,
            fn (ScheduledTaskSkipped $event) => optional(MonitoredScheduledTask::findForTask($event->task))->markAsSkipped($event)
        );
    }
}

When we hear the ScheduledTaskFinished come in, this code is executed:

optional(MonitoredScheduledTask::findForTask($event->task))->markAsFinished($event);

That findForTask function under the hood uses the ScheduleTaskFactory shown above to wrap the event in a Task class.

public static function findForTask(Event $event): ?self
{
    $task = ScheduledTaskFactory::createForEvent($event);

    if (empty($task->name())) {
        return null;
    }

    return MonitoredScheduledTask::findByName($task->name());
}

Finally, let's take a look at the markAsFinished function on MonitoredScheduledTask model.

public function markAsFinished(ScheduledTaskFinished $event): self
{
    if ($event->task->exitCode !== 0 && ! is_null($event->task->exitCode)) {
        return $this->markAsFailed($event);
    }

    $logItem = $this->createLogItem(MonitoredScheduledTaskLogItem::TYPE_FINISHED);

    $logItem->updateMeta([
        'runtime' => $event->runtime,
        'exit_code' => $event->task->exitCode,
        'memory' => memory_get_usage(true),
    ]);

    $this->update(['last_finished_at' => now()]);

    return $this;
}

The first thing that we'll do it check the exit code. Even if a scheduled task signals an error through a non-zero exit code, Laravel will regard it as "finished" and not "failed". But in our package, we'll mark the task as failed if a non-zero exit code is used.

The rest of this function is pretty trivial. A log item model is created where we store some extra properties like how long the command ran and how much memory it did take. To mark the task as finished, we're updating that last_finished_at attribute on the monitor model.

Viewing the monitored tasks

Now that you understand how tasks are registered in the package, and how listed for events, let's take a look at how we display the schedule.

Here's the output of list command.

In the screenshot, you only see the monitored commands, but the command can also display tables of unregistered tasks, duplicate tasks, and tasks that could not be tracked because no name could be determined for them.

When you look at the code of list command you'll see that I isolated each table in its own class.

class ListCommand extends Command
{
    public $signature = 'schedule-monitor:list';

    public $description = 'Display monitored scheduled tasks';

    public function handle()
    {
        (new MonitoredTasksTable($this))->render();
        (new ReadyForMonitoringTasksTable($this))->render();
        (new UnnamedTasksTable($this))->render();
        (new DuplicateTasksTable($this))->render();

        $this->line('');
    }
}

I'm not going to dive into each class as the code is pretty straightforward. We'll just loop over the registered task and use attributes on the monitor models, like last_finished_at, to display a task's status.

In closing

I hope you enjoyed this little source dive of spatie/laravel-schedule-monitor.

If you want to source dive some more of our packages with me, consider picking up the Laravel Package Training video course. Besides teaching you how to code up quality packages, it contains videos where we'll source dive laravel-medialibrary, laravel-multitenancy, and a couple more!

Be sure also to take a look at these packages our team has created previously. I'm sure there's something there for your next project.

Stay up to date with all things Laravel, PHP, and JavaScript.

Follow me on Twitter. I regularly tweet out programming tips, and what I myself have learned in ongoing projects.

Every two weeks I send out a newsletter containing lots of interesting stuff for the modern PHP developer.

Expect quick tips & tricks, interesting tutorials, opinions and packages. Because I work with Laravel every day there is an emphasis on that framework.

Rest assured that I will only use your email address to send you the newsletter and will not use it for any other purposes.

Comments

Webmentions

Freek Van der Herten replied on 28th July 2020
Then you should manually let your scheduled task ping an endpoint at Oh Dear.
Danilo Polani replied on 28th July 2020
And what if we're not using Laravel? How can OhDear deal with some "custom" cronjobs?
Oluwatobi Samuel Omisakin liked on 15th July 2020
Willan Correia liked on 15th July 2020
Matt J Cwanek liked on 15th July 2020
JR Lawhorne liked on 15th July 2020
Zypbgxzy X liked on 15th July 2020
Aidan Casey liked on 15th July 2020
Mainul Hassan Main liked on 15th July 2020
TheSteed liked on 15th July 2020
Mohsin Patel liked on 15th July 2020
xenon mangekyou liked on 15th July 2020
Neerav Pandya liked on 15th July 2020
Roman Pronskiy liked on 15th July 2020
Runar Jørgensen liked on 15th July 2020
Mattia Migliorini liked on 15th July 2020
Mitch Mertz liked on 15th July 2020
ali ali liked on 15th July 2020
Abhishek Jain liked on 15th July 2020
soroush safari liked on 15th July 2020
the-ostia.com liked on 15th July 2020
Tyler Woonton liked on 15th July 2020
UT liked on 15th July 2020
Preetesh liked on 15th July 2020
Amin Norouzi liked on 15th July 2020
Brent Garner liked on 15th July 2020
Farhan Hasin Chowdhury liked on 15th July 2020
elemental-gamma liked on 15th July 2020
Rohit P. Shirke liked on 15th July 2020
martin naughton liked on 15th July 2020
Jethro May liked on 15th July 2020
Mark Szymik 🔥 liked on 15th July 2020
Jigal Sanders liked on 15th July 2020
Husson Kévin liked on 15th July 2020
Stéphane Reynders (Spout) liked on 15th July 2020
Tobias Scharikow 🦊 liked on 15th July 2020
Abhishek Bhardwaj liked on 15th July 2020
Arash Rasoulzadeh ␀ liked on 15th July 2020
NUNO MADURO liked on 15th July 2020
Rihards Ščeredins liked on 15th July 2020
Michael Blair liked on 15th July 2020
Edoardo Masselli liked on 15th July 2020
Will Vincent liked on 15th July 2020
Macsi liked on 15th July 2020
Vincenzo La Rosa liked on 15th July 2020
Scorp974 liked on 15th July 2020
Abhitesh Das liked on 15th July 2020
Christian Klemp liked on 15th July 2020
Jason Klein liked on 15th July 2020
Van den Dikkenberg liked on 15th July 2020
Alejandro Pérez liked on 15th July 2020
Fatih liked on 15th July 2020
Johannes Brückner liked on 15th July 2020
Christopher liked on 15th July 2020
Shane Deakin liked on 15th July 2020
stephen Enikanosleu liked on 15th July 2020
Axel Pardemann liked on 15th July 2020
Flamur Mavraj liked on 15th July 2020
Jose Cerrejon liked on 15th July 2020
palash barua liked on 15th July 2020
Ilay Almalem liked on 15th July 2020
Mahesh Bhanushali🇮🇳 liked on 15th July 2020
sergiodk5 liked on 15th July 2020
najdou liked on 15th July 2020
audetcameron replied on 15th July 2020
i'm excited to try this out
audetcameron liked on 15th July 2020
newwave liked on 15th July 2020
Murali ⌘ Krishna liked on 15th July 2020
Risal Hidayat liked on 15th July 2020
Andre Madarang liked on 15th July 2020
Devlin liked on 15th July 2020
Arslan Ramay liked on 15th July 2020
Geoffrey van Wyk liked on 15th July 2020
Angel Campos Muñoz liked on 15th July 2020
Arslan Ramay retweeted on 15th July 2020
Gabeta Soro retweeted on 15th July 2020
Dejan Blazeski liked on 15th July 2020
Gabeta Soro liked on 15th July 2020
Jordan Mussi liked on 15th July 2020
Linkstack.xyz liked on 15th July 2020
Mickey liked on 15th July 2020
Mior Muhammad Zaki retweeted on 15th July 2020
David Cottila retweeted on 15th July 2020
Andrée-Anne Vincent retweeted on 15th July 2020
ad retweeted on 15th July 2020
nsulistiyawan liked on 15th July 2020
Travis Elkins liked on 15th July 2020
Suraj Adsul liked on 15th July 2020
Ahmad. liked on 15th July 2020
ad liked on 15th July 2020
Raul liked on 15th July 2020
Sinan Eldem liked on 15th July 2020
Mior Muhammad Zaki liked on 15th July 2020
João Guilherme liked on 15th July 2020
Marc Aubé liked on 15th July 2020
Kpikaza liked on 15th July 2020
Thibault Six liked on 15th July 2020
Abubakar Sadiq Umar liked on 15th July 2020
Ar 👨‍💻 liked on 14th July 2020
Simi Oluwatomi liked on 14th July 2020
Bantu liked on 14th July 2020
Guido W. Pettinari liked on 14th July 2020
Eddy Cortez liked on 14th July 2020
mimremo liked on 14th July 2020
Burhan liked on 14th July 2020
isramv liked on 14th July 2020
Mike liked on 14th July 2020
⚡404⚡ liked on 14th July 2020
Alex liked on 14th July 2020
Peter White liked on 14th July 2020
Matthew Poulter liked on 14th July 2020
undefined liked on 14th July 2020
Cofe Ding liked on 14th July 2020
Salman Zafar liked on 14th July 2020
Yerson Arce liked on 14th July 2020
Wyatt liked on 14th July 2020
Simi Oluwatomi retweeted on 14th July 2020
Jan Boddez liked on 14th July 2020
Lucas Fiege retweeted on 14th July 2020
Toan Nguyen retweeted on 14th July 2020
Nandor Sperl liked on 14th July 2020
Kevin Schenkers liked on 14th July 2020
Fabio liked on 14th July 2020
Gianluca Bine liked on 14th July 2020
Bruno Montibeller liked on 14th July 2020
dan_in_tweets liked on 14th July 2020
🧢 Win Monaye liked on 14th July 2020
Dixens liked on 14th July 2020
Albert Suntic liked on 14th July 2020
Barna Szalai liked on 14th July 2020
ali kenan liked on 14th July 2020
Eric Fletcher liked on 14th July 2020
Odinn Adalsteinsson liked on 14th July 2020
{coding:x} retweeted on 14th July 2020
Iman liked on 14th July 2020
Full-stack Utrecht retweeted on 14th July 2020
Aleksey A liked on 14th July 2020
Jose Alberto Lopez liked on 14th July 2020
Stephen Shead liked on 14th July 2020
Full-stack Utrecht liked on 14th July 2020
Sérgio Jardim liked on 14th July 2020
Daniel Lucas liked on 14th July 2020
Freek Van der Herten replied on 14th July 2020
See freek.dev/uses
Spatie retweeted on 14th July 2020
Aniket Mahadik retweeted on 14th July 2020
Ok9xNirab👌👌 retweeted on 14th July 2020
Carlos Ufano retweeted on 14th July 2020
liked on 14th July 2020
Jk Que liked on 14th July 2020
Pascal Baljet liked on 14th July 2020
Aniket Mahadik liked on 14th July 2020
Heru Hang Tryputra liked on 14th July 2020
François liked on 14th July 2020
Xu liked on 14th July 2020
Sonny Gauran 🇵🇭 liked on 14th July 2020
Ok9xNirab👌👌 liked on 14th July 2020
Marc Hampson liked on 14th July 2020
Yannick Yayo liked on 14th July 2020
Abinaya from Remote Leaf replied on 14th July 2020
ha, that's great 👍
غيث retweeted on 14th July 2020
Lawrence Enehizena liked on 14th July 2020
Pratik Shah retweeted on 14th July 2020
juan pineda liked on 14th July 2020
Redwan Abdullah liked on 14th July 2020
Laurent PASSEBECQ liked on 14th July 2020
Hassan Hafeez liked on 14th July 2020
🌲Nikolay Strikhar🌲 liked on 14th July 2020
Siddharth Ghedia liked on 14th July 2020
hosmel liked on 14th July 2020
Jason Young liked on 14th July 2020
Hunter Ray liked on 14th July 2020
Pratik Shah liked on 14th July 2020
JOHN DOE liked on 14th July 2020
Peter K. liked on 14th July 2020
Omar Andrés Barbosa Ortiz liked on 14th July 2020
Richard van Baarsen liked on 14th July 2020
Richard Radermacher liked on 14th July 2020
Hans H. Appel liked on 14th July 2020
Ward Hus liked on 14th July 2020
Abinaya from Remote Leaf replied on 14th July 2020
Hey, I really like the terminal colors/font and theme. Can you please share them?
Laurent PASSEBECQ replied on 14th July 2020
Thanx. Can be so usefull :)
Mattias Geniar retweeted on 14th July 2020
Arputharaj retweeted on 14th July 2020
Paulsky retweeted on 14th July 2020
LaravelRestify retweeted on 14th July 2020
Oláńrewájú retweeted on 14th July 2020
PHP Synopsis retweeted on 14th July 2020
Haneef Ansari 🍭 liked on 14th July 2020
Eduard Lupacescu retweeted on 14th July 2020
Kirlled Anderson liked on 14th July 2020
Martin Dufresne liked on 14th July 2020
Silence Ronald liked on 14th July 2020
Andrés liked on 14th July 2020
Joel Kahnwald. liked on 14th July 2020
Elsayed Awdallah liked on 14th July 2020
Ostap Bregin 🇺🇦 liked on 14th July 2020
Manish Manghwani liked on 14th July 2020
Andrew Broberg liked on 14th July 2020
Sam Wrigley liked on 14th July 2020
Hatam liked on 14th July 2020
NAbeel Yousaf liked on 14th July 2020
Mattias Geniar liked on 14th July 2020
Seba Ramírez Pastore liked on 14th July 2020
Tomi Filppu liked on 14th July 2020
Paulsky liked on 14th July 2020
Florian liked on 14th July 2020
Arputharaj liked on 14th July 2020
Freddy Vargas liked on 14th July 2020
Dep Infect liked on 14th July 2020
Víctor Moral liked on 14th July 2020
Martin Reitschmied liked on 14th July 2020
Parthasarathi G K liked on 14th July 2020
Do Hoang Dinh Tien retweeted on 14th July 2020
Dries Vints retweeted on 14th July 2020
kapil retweeted on 14th July 2020
ArielMejiaDev liked on 14th July 2020
Anne Koepcke retweeted on 14th July 2020
Maxime liked on 14th July 2020
Afzal Abbas liked on 14th July 2020
/dev/faisal liked on 14th July 2020
Denis Žoljom liked on 14th July 2020
Kevin Buchholz liked on 14th July 2020
Maurice Hofman liked on 14th July 2020
Paul Ayuk liked on 14th July 2020
Yann Haefliger liked on 14th July 2020
Gathuku liked on 14th July 2020
Mazin 🚀 liked on 14th July 2020
Chris⚡Codes liked on 14th July 2020
Cristi Jora liked on 14th July 2020
Julian Martin liked on 14th July 2020
Manuel Pijierro Sa liked on 14th July 2020
Luis Velasquez liked on 14th July 2020
Anne Koepcke liked on 14th July 2020
Craig Potter liked on 14th July 2020
Michaël De Boey liked on 14th July 2020
Marvin liked on 14th July 2020
Colin Hall liked on 14th July 2020
Andre Sayej liked on 14th July 2020
Cyril de Wit liked on 14th July 2020
Patrick Brouwers liked on 14th July 2020
Dries Vints liked on 14th July 2020
Anggit Ari Utomo 🐘 liked on 14th July 2020
Doug Black Jr liked on 14th July 2020
Manuel Pirker-Ihl replied on 14th July 2020
rly nice!
The Beyonder 🔥 liked on 14th July 2020
Manuel Pirker-Ihl liked on 14th July 2020