DEV Community

Andrew Welch
Andrew Welch

Posted on • Originally published at nystudio107.com on

1

Creating a Custom Logger for Craft CMS

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?

Andrew Welch / nystudio107

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.

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.

Cus­tomiz­ing the exist­ing 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.

Cus­tom Plugin/​Module Log­ging 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</kbd> (the * is a wildcard).

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.

A Com­plete­ly Cus­tom 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.

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.

Log­ging 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.

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!

Further Reading

If you want to be notified about new articles, follow nystudio107 on Twitter.

Copyright ©2020 nystudio107. Designed by nystudio107

AWS Q Developer image

Your AI Code Assistant

Automate your code reviews. Catch bugs before your coworkers. Fix security issues in your code. Built to handle large projects, Amazon Q Developer works alongside you from idea to production code.

Get started free in your IDE

Top comments (0)

Qodo Takeover

Introducing Qodo Gen 1.0: Transform Your Workflow with Agentic AI

Rather than just generating snippets, our agents understand your entire project context, can make decisions, use tools, and carry out tasks autonomously.

Read full post

Instrument, monitor, fix: a hands-on debugging session

Join Lazar for a hands-on session where you’ll build it, break it, debug it, and fix it. You’ll set up Sentry, track errors, use Session Replay and Tracing, and leverage some good ol’ AI to find and fix issues fast.

Tune in to the full event

DEV is partnering to bring live events to the community. Join us or dismiss this billboard if you're not interested. ❤️