Andrew Welch
Published , updated · 5 min read · RSS Feed
Please consider 🎗 sponsoring me 🎗 to keep writing articles like this.
Creating a Custom Logger for Craft CMS
The built-in Craft CMS logging system is sufficient for most needs, but what if you need custom logging, or want to send your logs to a third-party service?
The Craft CMS logging system (based on the Yii Logging system) is usually sufficient for most needs. Indeed, many people don’t even think about it until something goes wrong.
But what if you need to change the built-in logging behavior? Or you need to implement some kind of custom logging behavior? Or you want to send the Craft CMS logs to a SaaS that handles log aggregation such as PaperTrail or Sentry?
We’ll learn how to handle all three of the above scenarios in this article. We won’t cover how to read standard Craft CMS log files; for that check out the Zen and the Art of Craft CMS Log File Reading article.
Custom logging in Craft CMS is pretty easy to do, thanks to Yii
To create a custom logger we’ll need to use a bit of config or code, depending on what we’re trying to do. Thankfully Yii makes this pretty painless to do, so let’s get going!
The web app that is Craft CMS relies pretty heavily on Yii Configurations as a way to dynamically configure itself. It’s using Dependency Injection Containers under the hood to accomplish it.
While you don’t have to fully understand either to follow along in this article, it will help to get a complete sense of what’s going on.
Link Customizing the existing Logger
The first thing you might consider doing is customizing the existing file-based logger that Craft CMS uses by default.
Here’s an example of what properties you can change with just a custom configuration:
- includeUserIp — default: false — Whether the user IP should be included 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 rotated when they reach a certain maxFileSize
- maxFileSize — default: 10240 — Maximum log file size, in kilo-bytes
- maxLogFiles — default: 5 — Number of log files used for rotation
- levels — default: 0 (all) with devMode on, 3 (Logger::LEVEL_ERROR | Logger::LEVEL_WARNING) with devMode off — A bitmask of log levels that should be recorded in the log
There are a couple of other settings you can customize too, but they typically aren’t very interesting. See FileTarget.php for details.
So how can we do this? We can configure 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 nothing we can’t handle! We’re setting the log component to an anonymous function that gets a base $config from the built-in Craft helper method craft\helpers\App::logConfig().
I imagine that Pixel & Tonic wrote a helper method because the default logger config 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 everything it’s doing here, but let’s at least have a look at what this $config typically 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'
]
]
]
Looking at it this way, it makes a whole lot more sense why we’re doing:
$config['targets'][0]['enableRotation'] = true;
The log config has a class of yii\log\Dispatcher which almost all loggers will have, because this core Yii component handles dispatching messages from a logger to an arbitrary number of registered targets.
And that’s the other property we’re setting in the config, an array of targets. The target arrays have a configuration for an arbitrary number of classes that extend the abstract class yii\log\Target.
The first (0th) element in the targets array is Craft CMS’s default file target, so we can set config settings for that log target here, enableRotation in this case.
So let’s look at a practical example. For Craft console requests run via the command line, devMode 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 effectively make console requests work just like web requests, in that it’ll only log Logger::LEVEL_ERROR | Logger::LEVEL_WARNING log levels if devMode is off.
Link Custom Plugin/Module Logging Behavior
Another use case for custom logging behavior is if you have a plugin or module that you want to send all of its log messages to a separate file.
Maybe for debugging purposes, we want to be able to look at just the messages that our plugin or module logged without any other log messages muddying the waters.
We can actually do this very easily, just by adding a bit of code to our plugin or module’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 creates a new craft\log\FileTarget initialized with a custom logFile set to @storage/logs/retour.log. It when gets the current logger via Craft::getLogger() and adds the newly created $fileTarget to the array of existing targets in the dispatcher.
We set the categories array to just one item: nystudio107\retour\*. This means that this log target will only be sent log messages when the category is set to anything that begins with nystudio107\retour\ (the * is a wildcard).
This allows us to leverage the existing logging system very effectively.
This will log any messages coming from the Retour plugin’s namespace nystudio107\retour\* because when we log, we do Craft::error('message', __METHOD__);
The __METHOD__ PHP magic constant outputs the current fully\qualified\class::method.
The nice thing about doing it this way is that in addition to getting just our plugin’s messages logged to a separate log file, they also still go to to primary log @storage/logs/web.log file. We just added an additional log target.
This matters because then we can still use the Yii Debug Toolbar to sift through all of the logs, as described in the Profiling your Website with Craft CMS 3’s Debug Toolbar article.
N.B.: This method is a refinement of the method used by Robin here; there’s also a variant by Abraham here.
Link A Completely Custom Logger
Let’s say you want a completely custom logger, perhaps to send the Craft CMS logs to a SaaS that handles log aggregation such as PaperTrail or Sentry or the like.
In this case, we’ll need both some custom config as well as custom code. Let’s start with the config; we’ll once again need to put a custom log component 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 completely replace the log component with our own via a configuration array. The target’s class points to a custom log target in our site module’s namespace at modules\sitemodule\log\CustomTarget.
If you’re not familiar with custom modules, check out the Enhancing a Craft CMS 3 Website with a Custom Module article.
We have a few other properties that we’re configuring here:
- logFile — the file we’re logging to (see below)
- levels — the log levels we’re interested in, in this case just error & warning
- exportInterval — how many messages should be accumulated before they are exported
There are other properties we might configure here, too; see Target.php for more.
You might wonder why we specify a logFile here, if we’re going to be sending our log messages to a SaaS somewhere out in the cloud anyway.
The reason is that we want a local log as well, in case something goes awry with our logging aggregation service, or we simply want to be able to debug & work completely offline.
For this reason, our CustomTarget class extends the built-in FileTarget class, so we can inherit all of its file logging goodness for free, while adding our own custom bit to transmit 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 override only a single method export() which is called by the logger at exportInterval when it is time to export the messages to an external destination.
First we call parent::export() to let our FileTarget parent class do its thing exporting the log messages to a file.
Then we call the parent Target class’s formatMessage method as a PHP Callable that we pass into the array_map function, which returns an array of formatted log lines that we then JSON encode.
Finally, we create a Guzzle client and send off our JSON-encoded messages along with our auth to an example API.
While the code you’d use to communicate with an actual SaaS would be slightly different depending on their API, the approach is the same.
And as you can see, we didn’t write all that much code to accomplish it.
Link Monolog as a Logger
If we’re customizing the Craft CMS logger, a very typical use-case is that we’re just sending the log data to some third party logging service such as PaperTrail or Sentry. We saw in the previous section that we could do that by writing our own custom logger.
But we can also do it by leveraging a PHP library called Monolog. Monolog is sort of a meta logger, in that it handles sending your logs to files, sockets, inboxes, databases and various web services.
Thankfully, this is very easy to set up as a log target 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 Target in our project:
composer require "samdark/yii2-psr-log-target"
Then we just need to add some config to our config/app.php file to tell it to use Monolog, and configure Monolog to send the logging 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 handler that logs to the standard php IO streams (which then might be going elsewhere), but Monolog supports many third party packages.
In addition, it’s so popular that there are configs explicitly listed for services like Papertrail and Sentry.
Link Logging Out
We’ve just scratched the surface of what you could potentially do with custom logging. For example, you could have it normally log to a file, but if there’s an error log level, you could have it email someone important.
Hopefully this article has given you have some idea of the methodology to use when approaching custom logging.
Embrace & extend, don’t reinvent the wheel
Yii has some fantastic logging facilities built into it. Leverage the work that they have done, and hoist your custom code on top of it.
Happy logging!