Logs and Reports
Where Magento writes system.log, debug.log and exception.log, what the error id on a customer's screen points to, and how to log from your own class.
$logger->critical(string|Stringable $message, array $context = ['exception' => $e]): voidThree files, three jobs
Magento logs through PSR-3. Inject Psr\Log\LoggerInterface and dependency injection gives you Magento\Framework\Logger\Monolog, a Monolog logger wired to file handlers. var/log/system.log takes records at INFO and above. var/log/debug.log takes DEBUG records, and it is only written when the application is not in production mode. var/log/exception.log is the odd one: nothing targets it by name. The system handler looks at the record's context array, and if it finds an exception key it diverts the whole record to the exception file instead.
Reports and the id the customer sees
An uncaught exception in production mode never reaches the browser. The error page prints Error log record number followed by an id, and the exception data is serialised as JSON into var/report/ under that id as its filename. That id is the entire link between a customer complaint and a stack trace, so always ask for it.
What trips people up
Nothing under var/log needs a cache flush; the files are appended on the next request. A new log file declared in di.xml does need one, because it is built from virtual types: run bin/magento cache:flush in developer mode, or bin/magento setup:di:compile followed by bin/magento cache:flush in production mode. An empty debug.log on a live store is not a broken logger. It is production mode behaving as designed.
Example
<?php
// app/code/Vendor/Pricing/Model/CatalogSync.php
declare(strict_types=1);
namespace Vendor\Pricing\Model;
use Magento\Framework\Exception\LocalizedException;
use Psr\Log\LoggerInterface;
class CatalogSync
{
/**
* @var LoggerInterface
*/
private LoggerInterface $logger;
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}
public function sync(string $sku): bool
{
$this->logger->info('Price sync started', ['sku' => $sku]);
try {
$this->readFeed($sku);
} catch (LocalizedException $e) {
$this->logger->critical($e->getMessage(), ['exception' => $e]);
return false;
}
return true;
}
private function readFeed(string $sku): void
{
throw new LocalizedException(__('Feed unavailable for %1', $sku));
}
}
// Result: the INFO line lands in var/log/system.log with its context
// rendered as JSON. The CRITICAL record does not: because the context
// carried an 'exception' key, the system handler sends it to
// var/log/exception.log together with the stack trace.When to use it
- A customer sends a screenshot showing an error log record number and you need the stack trace behind it.
- A nightly price feed fails silently and you want its history in its own file rather than buried in system.log.
- Confirming whether a cron job ran at all before you start debugging its logic.
- Checking why debug.log is empty on a live store after someone added debug calls.
More examples
Give a module its own log file
A virtual type is a named, pre-configured version of an existing class; it creates no PHP file. The first one is a file handler pointed at var/log/pricing_feed.log, the second is a Monolog logger that uses only that handler, and the <type> block hands it to CatalogSync in place of the shared logger. The class itself does not change: it still asks for LoggerInterface. After editing this file run bin/magento cache:flush in developer mode, or bin/magento setup:di:compile then bin/magento cache:flush in production mode. Reach for this when an integration is noisy and you want its history separate from everything else in system.log.
<!-- app/code/Vendor/Pricing/etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<virtualType name="Vendor\Pricing\Logger\Handler"
type="Magento\Framework\Logger\Handler\Base">
<arguments>
<argument name="fileName" xsi:type="string">/var/log/pricing_feed.log</argument>
</arguments>
</virtualType>
<virtualType name="Vendor\Pricing\Logger\Logger"
type="Magento\Framework\Logger\Monolog">
<arguments>
<argument name="name" xsi:type="string">pricingFeed</argument>
<argument name="handlers" xsi:type="array">
<item name="feed" xsi:type="object">Vendor\Pricing\Logger\Handler</item>
</argument>
</arguments>
</virtualType>
<type name="Vendor\Pricing\Model\CatalogSync">
<arguments>
<argument name="logger" xsi:type="object">Vendor\Pricing\Logger\Logger</argument>
</arguments>
</type>
</config>Promoted constructor properties are the same injection
Magento core is written in the long form, so that is what you will read in vendor/magento. Newer module code often uses the promoted form. They desugar to the same thing: a property declaration plus an assignment. Recognising both matters because dependency injection only ever looks at the constructor's parameter types and names, not at where the property is declared. The readonly modifier adds nothing for DI; it only stops later reassignment.
<?php
// app/code/Vendor/Pricing/Model/LongForm.php
declare(strict_types=1);
namespace Vendor\Pricing\Model;
use Psr\Log\LoggerInterface;
class LongForm
{
private LoggerInterface $logger;
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}
}
// Exactly the same class, written with constructor property promotion
// (PHP 8.0 and later; this course targets PHP 8.4):
class ShortForm
{
public function __construct(
private readonly LoggerInterface $logger
) {
}
}
// Both declare a private LoggerInterface property and both assign the
// injected argument to it. Magento reads the constructor signature, so
// the object manager resolves LoggerInterface identically in each case.Turn a report id into a stack trace
This is the whole production workflow for a 500 error that a customer saw. The id names the file, the file holds the serialised exception, and the framework logs the same event as a CRITICAL record whose context carries the report_id; since that context has no 'exception' key the record goes to system.log, with a timestamp you can line up against web server access logs. Do this before you consider changing anything on the server, because the evidence is already on disk.
# A customer screenshot says:
# Error log record number:
# 44ffb1087a44e61b018b3cdee72284d017f22e52755c24e5c85cbac1647ae7a7
# In 2.4 the id is a 64 character hash of the message and trace, not a number.
ID=44ffb1087a44e61b018b3cdee72284d017f22e52755c24e5c85cbac1647ae7a7
# Reports can be stored in nested subdirectories, so search rather than guess.
find var/report -name "$ID"
# var/report/44ffb1087a44e61b018b3cdee72284d017f22e52755c24e5c85cbac1647ae7a7
# The file is JSON. Pretty-print it to read the message and the trace.
php -r 'print_r(json_decode(file_get_contents($argv[1]), true));' \
"$(find var/report -name "$ID" | head -n 1)"
# The framework logs the same event with the id in its context, and because
# that context has no 'exception' key the record lands in system.log.
grep -n "$ID" var/log/system.log
tail -n 100 var/log/system.log
Discussion