Laravel Custom Logging

May 7, 2021 by Alexandre | 1896 views

Laravel PHP

https://cylab.be/blog/144/laravel-custom-logging

For the majority of developed program, it is necessary to use a robust and efficient Log mechanism. It is of course the case for a web interface. It is important to be able to track potential bugs or issues.

Laravel, a very powerful web framework, provides a good Log mechanism. For basic usage, it is not necessary to configure anything: the exceptions triggered will be logged in a file located in storage/logs/ folder. But sometimes, for a specific project, the default configuration is not enough.

In this blog post, we will describe a complete custom logging system in Laravel. This logging system is used to track in real-time the progression of long job execution. Concretely, the job execution uses an external composer dependency that has its own log system for progression. The goal is to display some information about this dependency in the web interface.

class DummyJob implements ShouldQueue
{
    public function __construct()
    {
        //Code constructor
    }
    public function handle()
    {
        //Some code

        $logger = new Logger('logger-test');
        $dependency = new ComposerDependency($logger, $a, $b);
        $dependency->run();

        //Some code
    }
}

We want to take back and display some information produce by the ComposerDependency class during the run() execution method that is an algorithm with several iterations in it.

A possible method to perform this task is to store these logs in the database used in your Laravel project (SQL, SQLite, MariaDB,...).

Create database migration

The first step is to create a database migration to store your logs in a specific table.

PHP artisan make:migration create_logs_table

The following code is a classical valid example for the migration class.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateLogsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('logs', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
            $table->enum('level', ['emergency', 'alert', 'critical', 'error', 'warning', 'notice', 'info', 'debug']);
            $table->integer('iteration')->nullable();
            $table->text('message')->nullable();
            $table->text('user_id')->nullable();
            $table->integer('job_id')->nullable();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('logs');
    }
}

The table will contain:

  • a unique identifier
  • created_at timestamp (generated by $table->timestamps() )
  • updated_at timestamp (generated by $table->timestamps() )
  • an enumeration of the different possible log level
  • an iteration field (specific for this usage)
  • the complete log message
  • the user_id that trigger the job
  • the job_id

Of course, all these fields can be modified/deleted/added to match the needs of your project.

Laravel channel configuration

The Laravel log configuration file is config/logging.php. This file contains several pre-configured log channels. For example:

'default' => env('LOG_CHANNEL', 'single'),

'channels' => [
        'stack' => [
            'driver' => 'stack',
            'channels' => ['single'],
            'ignore_exceptions' => false,
        ],

        'single' => [
            'driver' => 'single',
            'path' => storage_path('logs/laravel.log'),
            'level' => 'debug',
        ],

        'daily' => [
            'driver' => 'daily',
            'path' => storage_path('logs/laravel.log'),
            'level' => 'debug',
            'days' => 14,
        ],
//More code

In this case, we have to create a complete custom log channel:

'channels' => [
        'custom' => [
            'driver' => 'custom',
            'via' => App\Logging\LogJob::class,
        ],

The custom driver means we use our own Log class that is specified in the via configuration field. This App\Logging\LogJob::class is the class we will create and use in this project.

Create your Monolog instance

The class which was declared in the channel custom with the via property only needs one magical method __invoke(). This method has to return a Logger instance. In the custom Log class, two other classes are instantiated: a LogJobHandler and a LogProcessor. These Handler and Processor are pushed in the Logger instance.

<?php

namespace App\Logging;

use Monolog\Logger;

class LogJob
{
    public function __invoke(): Logger
    {
        $logger = new Logger('training_logger');
        $handler = new LogJobHandler();
        $processor = new LogProcessor();
        $logger->pushHandler($handler);
        $logger->pushProcessor($processor);
        return $logger;
    }
}

Custom Handler

To create a custom Handler, we have to implement the Monolog\Handler\HandlerInterface. Our custom Handler extends the abstract class AbstractProcessingHandler (that implements the HandlerInterface). Our custom Handler implements two methods:

  • getDefaultFormatter() : this method returns a FormatterInterface
  • write(array $record): the record is the elements that will be stored in the DB.

A Handler is a class that is pushed in a stack. When a Log is added, it traverses the handler stack.

<?php

namespace App\Logging;

use App\Events\LogJobEvent;
use Monolog\Formatter\FormatterInterface;
use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Logger;

class LogJobHandler extends AbstractProcessingHandler
{
    public function __construct($level = Logger::DEBUG, bool $bubble = true)
    {
        parent::__construct($level, $bubble);
    }

    /**
     * @inheritDoc
     */
    protected function write(array $record): void
    {
        $log = new LogJob();
        $log->fill($record['formatted']);
        $log->save();
    }

    protected function getDefaultFormatter(): FormatterInterface
    {
        return new LogFormatter();
    }
}

Custom Processor

This class extend the default fields for the log entry with some extra information. The class must have the magical method __invoke(array $record)

<?php

namespace App\Logging;

class LogProcessor
{

    public function __invoke(array $record): array
    {
        $record['extra'] = [
            'user_id' => auth()->user() ? auth()->user()->id : null,
        ];
        return $record;
    }
}

Custom Formatter

Our custom Handler class has a getDefaultFormatter() method that returns a custom Formatter. This custom Formatter will format the log to be saved in the Database.

<?php

namespace App\Logging;

use Monolog\Formatter\NormalizerFormatter;

class LogFormatter extends NormalizerFormatter
{
    public function __construct()
    {
        parent::__construct();
    }

    public function format(array $record)
    {
        $record = parent::format($record);
        return $this->convertToDataBase($record);
    }

    protected function convertToDataBase(array $record)
    {
        $el = $record['extra'];
        $el['level'] = strtolower($record['level_name']);
        $el['message'] = $record['message'];
        $iteration = substr($record['message'], 0, strpos($record['message'], ' | Fitness'));

        return $el;
    }
}

The format(array $record) method receives a record as argument. This record contains the extra data from the LogProcessor class. The convertToDataBase(array $record) method adapt our record to match the database table structure and return the formatted object.

Log class

In your Log class (that extends the Model class), you can create your method according to your needs.

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Log extends Model
{
    protected $table = 'logs';

    protected $guarded = ['id'];

// Some code according to your needs
}

Separate reporter logs

The last needed step is to modify the App\Exceptions\Handler.php. Currently, your classical triggered exceptions will be stored on your database. You have to change the report(Exception $exception) method to specify the exception will be stored in another logging channel.

    public function report(Throwable $exception)
    {
        if ($this->shouldntReport($exception)) {
            return;
        }
        \Log::channel('single')->error(
            $exception->getMessage(),
            array_merge($this->context(), ['exception' => $exception])
        );
    }

Usage

In your .env file, specify the log channel to custom:

LOG_CHANNEL=custom

After that, each time a log is performed by calling the \Log facade, the record is passed to the Processor, then the Formatter, after that, the record is passed to the write method that triggers the Event with the formatted log.

Concretely, we can use the log system by doing:

\Log::info('Your Message');

If we come back to our example at the beginning of the article, we can see an issue with the facade usage. It is not possible to use them inside an external dependency.

class DummyJob implements ShouldQueue
{
    public function __construct()
    {
        //Code constructor
    }
    public function handle()
    {
        //Some code

        $logger = new Logger('logger-test');
        $dependency = new ComposerDependency($logger, $a, $b);
        $dependency->run();

        //Some code
    }
}

If for your usage you have to use a Logger object, it is possible to instantiate it by doing the following command:

$logger = \Log::channel('custom');
$logger->info('Your custom message');

Another specific usage: in some cases, it could be useful to add the Processor to the Logger instance directly from the Laravel Job code. If you want to track the Job Unique Identifier in the Log for example.

$logger = \Log::channel('custom');
$processor = new LogProcessor();
$processor->someActionNeeded($a);
$logger->pushProcessor($processor);