Andrew Welch · Insights · #craftcms #craft-3 #logs

Published , updated · 5 min read ·


Please consider 🎗 sponsoring me 🎗 to keep writing articles like this.

Creating a Custom Logger for Craft CMS

The built-in Craft CMS log­ging sys­tem is suf­fi­cient for most needs, but what if you need cus­tom log­ging, or want to send your logs to a third-par­ty service?

Writing a custom logger craft cms

The Craft CMS log­ging sys­tem (based on the Yii Log­ging sys­tem) is usu­al­ly suf­fi­cient for most needs. Indeed, many peo­ple don’t even think about it until some­thing goes wrong.

But what if you need to change the built-in log­ging behav­ior? Or you need to imple­ment some kind of cus­tom log­ging behav­ior? Or you want to send the Craft CMS logs to a SaaS that han­dles log aggre­ga­tion such as Paper­Trail or Sen­try?

We’ll learn how to han­dle all three of the above sce­nar­ios in this arti­cle. We won’t cov­er how to read stan­dard Craft CMS log files; for that check out the Zen and the Art of Craft CMS Log File Read­ing article.

Custom logging in Craft CMS is pretty easy to do, thanks to Yii

To cre­ate a cus­tom log­ger we’ll need to use a bit of con­fig or code, depend­ing on what we’re try­ing to do. Thank­ful­ly Yii makes this pret­ty pain­less to do, so let’s get going!

The web app that is Craft CMS relies pret­ty heav­i­ly on Yii Con­fig­u­ra­tions as a way to dynam­i­cal­ly con­fig­ure itself. It’s using Depen­den­cy Injec­tion Con­tain­ers under the hood to accom­plish it.

While you don’t have to ful­ly under­stand either to fol­low along in this arti­cle, it will help to get a com­plete sense of what’s going on.

Link Customizing the existing Logger

The first thing you might con­sid­er doing is cus­tomiz­ing the exist­ing file-based log­ger that Craft CMS uses by default.

Logging lumberjack 01

Here’s an exam­ple of what prop­er­ties you can change with just a cus­tom configuration:

  • includeUserIp — default: false — Whether the user IP should be includ­ed in the default log prefix
  • logFile — default: @storage/logs/web.log — The log file path or path alias
  • enableRotation — default: true — Whether log files should be rotat­ed when they reach a cer­tain maxFileSize
  • maxFileSize — default: 10240 — Max­i­mum log file size, in kilo-bytes
  • maxLogFiles — default: 5 — Num­ber of log files used for rotation
  • levels — default: 0 (all) with devMode on, 3 (Logger::LEVEL_ERROR | Logger::LEVEL_WARNING) with devMode off — A bit­mask of log lev­els that should be record­ed in the log

There are a cou­ple of oth­er set­tings you can cus­tomize too, but they typ­i­cal­ly aren’t very inter­est­ing. See FileTarget.php for details.

So how can we do this? We can con­fig­ure the web app that is Craft CMS via the config/app.php file by adding a log component:

<?php

return [
    'components' => [
        'log' => function() {
            $config = craft\helpers\App::logConfig();
            if ($config) {
                $config['targets'][0]['includeUserIp'] = false;
                $config['targets'][0]['logFile'] = '@storage/logs/web.log';
                $config['targets'][0]['enableRotation'] = true;
                $config['targets'][0]['maxFileSize'] = 10240;
                $config['targets'][0]['maxLogFiles'] = 5;
                return Craft::createObject($config);
            }
            return null;
        },
    ],
];

There’s a bit to unpack here, but noth­ing we can’t han­dle! We’re set­ting the log com­po­nent to an anony­mous func­tion that gets a base $config from the built-in Craft helper method craft\helpers\App::logConfig().

I imag­ine that Pix­el & Ton­ic wrote a helper method because the default log­ger con­fig that Craft CMS uses has some meat on its bones. Let’s have a look at it:

    /**
     * Returns the `log` component config.
     *
     * @return array|null
     */
    public static function logConfig()
    {
        // Only log console requests and web requests that aren't getAuthTimeout requests
        $isConsoleRequest = Craft::$app->getRequest()->getIsConsoleRequest();
        if (!$isConsoleRequest && !Craft::$app->getUser()->enableSession) {
            return null;
        }

        $generalConfig = Craft::$app->getConfig()->getGeneral();

        $target = [
            'class' => FileTarget::class,
            'fileMode' => $generalConfig->defaultFileMode,
            'dirMode' => $generalConfig->defaultDirMode,
            'includeUserIp' => $generalConfig->storeUserIps,
            'except' => [
                PhpMessageSource::class . ':*',
            ],
        ];

        if ($isConsoleRequest) {
            $target['logFile'] = '@storage/logs/console.log';
        } else {
            $target['logFile'] = '@storage/logs/web.log';

            // Only log errors and warnings, unless Craft is running in Dev Mode or it's being installed/updated
            if (!YII_DEBUG && Craft::$app->getIsInstalled() && !Craft::$app->getUpdates()->getIsCraftDbMigrationNeeded()) {
                $target['levels'] = Logger::LEVEL_ERROR | Logger::LEVEL_WARNING;
            }
        }

        return [
            'class' => Dispatcher::class,
            'targets' => [
                $target,
            ]
        ];
    }

We don’t need to grok every­thing it’s doing here, but let’s at least have a look at what this $config typ­i­cal­ly looks like when returned if devMode is on(thus levels is not limited):

[
    'class' => 'yii\\log\\Dispatcher'
    'targets' => [
        0 => [
            'class' => 'craft\\log\\FileTarget'
            'fileMode' => null
            'dirMode' => 509
            'includeUserIp' => false
            'except' => [
                0 => 'yii\\i18n\\PhpMessageSource:*'
            ]
            'logFile' => '@storage/logs/web.log'
        ]
    ]
]

Look­ing at it this way, it makes a whole lot more sense why we’re doing:

                $config['targets'][0]['enableRotation'] = true;

The log con­fig has a class of yii\log\Dispatcher which almost all log­gers will have, because this core Yii com­po­nent han­dles dis­patch­ing mes­sages from a log­ger to an arbi­trary num­ber of reg­is­tered targets.

And that’s the oth­er prop­er­ty we’re set­ting in the con­fig, an array of targets. The tar­get arrays have a con­fig­u­ra­tion for an arbi­trary num­ber of class­es that extend the abstract class yii\log\Target.

The first (0th) ele­ment in the targets array is Craft CMS’s default file tar­get, so we can set con­fig set­tings for that log tar­get here, enableRotation in this case.

So let’s look at a prac­ti­cal exam­ple. For Craft con­sole requests run via the com­mand line, dev­Mode is enabled by default, but we can fix this easily:

<?php

use craft\helpers\ArrayHelper;
use yii\log\Logger;

return [
    'components' => [
        'log' => function() {
            $config = craft\helpers\App::logConfig();
            if ($config) {
                $generalConfig = Craft::$app->getConfig()->getGeneral();
                $devMode = ArrayHelper::getValue($generalConfig, 'devMode', false);
                // Only log errors and warnings, unless Craft is running in Dev Mode or it's being installed/updated
                if (!$devMode && Craft::$app->getIsInstalled() && !Craft::$app->getUpdates()->getIsCraftDbMigrationNeeded()) {
                    $config['targets'][0]['levels'] = Logger::LEVEL_ERROR | Logger::LEVEL_WARNING;
                }

                return Craft::createObject($config);
            }
            return null;
        },
    ],
];

This will effec­tive­ly make con­sole requests work just like web requests, in that it’ll only log Logger::LEVEL_ERROR | Logger::LEVEL_WARNING log lev­els if devMode is off.

Link Custom Plugin/Module Logging Behavior

Anoth­er use case for cus­tom log­ging behav­ior is if you have a plu­g­in or mod­ule that you want to send all of its log mes­sages to a sep­a­rate file.

Logging lumberhack 03

Maybe for debug­ging pur­pos­es, we want to be able to look at just the mes­sages that our plu­g­in or mod­ule logged with­out any oth­er log mes­sages mud­dy­ing the waters.

We can actu­al­ly do this very eas­i­ly, just by adding a bit of code to our plu­g­in or mod­ule’s init() method:

public function init() {
    // Create a new file target
    $fileTarget = new \craft\log\FileTarget([
        'logFile' => '@storage/logs/retour.log',
        'categories' => ['nystudio107\retour\*']
    ]);
    // Add the new target file target to the dispatcher
    Craft::getLogger()->dispatcher->targets[] = $fileTarget;
}

This cre­ates a new craft\log\FileTarget ini­tial­ized with a cus­tom logFile set to @storage/logs/retour.log. It when gets the cur­rent log­ger via Craft::getLogger() and adds the new­ly cre­at­ed $fileTarget to the array of exist­ing targets in the dispatcher.

We set the categories array to just one item: nystudio107\retour\*. This means that this log tar­get will only be sent log mes­sages when the cat­e­go­ry is set to any­thing that begins with nystudio107\retour\ (the * is a wildcard).

This allows us to leverage the existing logging system very effectively.

This will log any mes­sages com­ing from the Retour plug­in’s name­space nystudio107\retour\* because when we log, we do Craft::error('message', __METHOD__);

The __METHOD__ PHP mag­ic con­stant out­puts the cur­rent fully\qualified\class::method.

The nice thing about doing it this way is that in addi­tion to get­ting just our plug­in’s mes­sages logged to a sep­a­rate log file, they also still go to to pri­ma­ry log @storage/logs/web.log file. We just added an addi­tion­al log target.

This mat­ters because then we can still use the Yii Debug Tool­bar to sift through all of the logs, as described in the Pro­fil­ing your Web­site with Craft CMS 3’s Debug Tool­bar article.

N.B.: This method is a refine­ment of the method used by Robin here; there’s also a vari­ant by Abra­ham here.

Link A Completely Custom Logger

Let’s say you want a com­plete­ly cus­tom log­ger, per­haps to send the Craft CMS logs to a SaaS that han­dles log aggre­ga­tion such as Paper­Trail or Sen­try or the like.

Logging lumberjack 03

In this case, we’ll need both some cus­tom con­fig as well as cus­tom code. Let’s start with the con­fig; we’ll once again need to put a cus­tom log com­po­nent in our config/app.php:

<?php

return [
    'components' => [
        'log' => [
            'class' => 'yii\\log\\Dispatcher',
            'targets' => [
                [
                    'class' => 'modules\sitemodule\log\CustomTarget',
                    'logFile' => '@storage/logs/custom.log',
                    'levels' => ['error', 'warning'],
                    'exportInterval' => 100,
                ],
            ],
        ],
    ],
];

In this case, we com­plete­ly replace the log com­po­nent with our own via a con­fig­u­ra­tion array. The tar­get’s class points to a cus­tom log tar­get in our site mod­ule’s name­space at modules\sitemodule\log\CustomTarget.

If you’re not famil­iar with cus­tom mod­ules, check out the Enhanc­ing a Craft CMS 3 Web­site with a Cus­tom Mod­ule article.

We have a few oth­er prop­er­ties that we’re con­fig­ur­ing here:

  • logFile — the file we’re log­ging to (see below)
  • levels — the log lev­els we’re inter­est­ed in, in this case just error & warning
  • exportInterval — how many mes­sages should be accu­mu­lat­ed before they are exported

There are oth­er prop­er­ties we might con­fig­ure here, too; see Target.php for more.

You might won­der why we spec­i­fy a logFile here, if we’re going to be send­ing our log mes­sages to a SaaS some­where out in the cloud anyway.

The rea­son is that we want a local log as well, in case some­thing goes awry with our log­ging aggre­ga­tion ser­vice, or we sim­ply want to be able to debug & work com­plete­ly offline.

For this rea­son, our CustomTarget class extends the built-in FileTarget class, so we can inher­it all of its file log­ging good­ness for free, while adding our own cus­tom bit to trans­mit data to our API:

<?php
/**
 * Site module for Craft CMS 3.x
 *
 * An example module for Craft CMS 3 that lets you enhance your websites with a custom site module
 *
 * @link      https://nystudio107.com/
 * @copyright Copyright (c) 2019 nystudio107
 */

namespace modules\sitemodule\log;

use Craft;
use craft\helpers\Json;
use craft\log\FileTarget;

/**
 * @author    nystudio107
 * @package   SiteModule
 * @since     1.0.0
 */
class CustomTarget extends FileTarget
{
    // Constants
    // =========================================================================

    const API_URL = 'https://api.example.com';
    const API_KEY = 'password';

    // Public Methods
    // =========================================================================

    /**
     * @inheritDoc
     */
    public function export()
    {
        // Let the parent FileTarget export the messages to a file
        parent::export();
        // Convert the messages into a JSON-encoded array of formatted log messages
        $messages = Json::encode(array_map([$this, 'formatMessage'], $this->messages));
        // Create a guzzle client, and send our payload to the API
        $client = Craft::createGuzzleClient();
        try {
            $client->post(self::API_URL, [
                'auth' => [
                    self::API_KEY,
                    '',
                ],
                'form_params' => [
                    'messages' => $messages,
                ],
            ]);
        } catch (\Exception $e) {
        }
    }
}

Let’s break down what we’re doing here. We over­ride only a sin­gle method export() which is called by the log­ger at exportInterval when it is time to export the mes­sages to an exter­nal destination.

First we call parent::export() to let our FileTarget par­ent class do its thing export­ing the log mes­sages to a file.

Then we call the par­ent Target class’s formatMessage method as a PHP Callable that we pass into the array_​map func­tion, which returns an array of for­mat­ted log lines that we then JSON encode.

Final­ly, we cre­ate a Guz­zle client and send off our JSON-encod­ed mes­sages along with our auth to an exam­ple API.

While the code you’d use to com­mu­ni­cate with an actu­al SaaS would be slight­ly dif­fer­ent depend­ing on their API, the approach is the same.

And as you can see, we did­n’t write all that much code to accom­plish it.

Link Monolog as a Logger

If we’re cus­tomiz­ing the Craft CMS log­ger, a very typ­i­cal use-case is that we’re just send­ing the log data to some third par­ty log­ging ser­vice such as Paper­Trail or Sen­try. We saw in the pre­vi­ous sec­tion that we could do that by writ­ing our own cus­tom logger.

But we can also do it by lever­ag­ing a PHP library called Monolog. Monolog is sort of a meta log­ger, in that it han­dles send­ing your logs to files, sock­ets, inbox­es, data­bas­es and var­i­ous web services.

Thank­ful­ly, this is very easy to set up as a log tar­get in Craft CMS, with just config!

Install Monolog in our project:

composer require "monolog/monolog"

Then we’ll also need to install Yii 2 PSR Log Tar­get in our project:

composer require "samdark/yii2-psr-log-target"

Then we just need to add some con­fig to our config/app.php file to tell it to use Monolog, and con­fig­ure Monolog to send the log­ging where we want it to:

<?php

use Monolog\Logger;
use Monolog\Handler\StreamHandler;

return [
    'components' => [
        'log' => [
            'targets' => [
                [
                    'class' => \samdark\log\PsrTarget::class,
                    'except' => ['yii\web\HttpException:40*'],
                    'logVars' => [],
                    'logger' => (new Logger('app'))
                        ->pushHandler(new StreamHandler('php://stderr', \Monolog\Logger::WARNING))
                        ->pushHandler(new StreamHandler('php://stdout', \Monolog\Logger::DEBUG)),
                    'addTimestampToContext' => true,
                ]
            ],
        ]
    ],
];

This just sets up a han­dler that logs to the stan­dard php IO streams (which then might be going else­where), but Monolog sup­ports many third par­ty pack­ages.

In addi­tion, it’s so pop­u­lar that there are con­figs explic­it­ly list­ed for ser­vices like Paper­trail and Sen­try.

Link Logging Out

We’ve just scratched the sur­face of what you could poten­tial­ly do with cus­tom log­ging. For exam­ple, you could have it nor­mal­ly log to a file, but if there’s an error log lev­el, you could have it email some­one important.

Logging lumberjack 06

Hope­ful­ly this arti­cle has giv­en you have some idea of the method­ol­o­gy to use when approach­ing cus­tom logging.

Embrace & extend, don’t reinvent the wheel

Yii has some fan­tas­tic log­ging facil­i­ties built into it. Lever­age the work that they have done, and hoist your cus­tom code on top of it.

Hap­py logging!