Oh Dear is the all-in-one monitoring tool for your entire website. We monitor uptime, SSL certificates, broken links, scheduled tasks and more. You'll get a notifications for us when something's wrong. All that paired with a developer friendly API and kick-ass documentation. O, and you'll also be able to create a public status page under a minute. Start monitoring using our free trial now.

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.

You can follow me on these platforms:

On all these platforms, regularly share programming tips, and what I myself have learned in ongoing projects.

Every month 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

What are your thoughts on "Introducing Laravel Schedule Monitor"?

Comments powered by Laravel Comments
Want to join the conversation? Log in or create an account to post a comment.