← Back to blog

Magento 2 Custom Log File with Monolog (No Extra Classes)

Rodrigo Biazzi

Every Magento integration eventually needs its own log. When an ERP export fails at 3 a.m., searching for the right lines in a system.log shared by every module is slow. A dedicated file with clear messages and context turns that into a one minute check.

Magento 2 uses Monolog behind the PSR-3 LoggerInterface. Thanks to dependency injection, you can give a class its own log file without writing a single new PHP class. This article shows that approach first, then a custom handler class for cases that need more control, and the practices that make logs useful.

How logging is wired in Magento

When a class asks for Psr\Log\LoggerInterface in its constructor, the core di.xml gives it Magento\Framework\Logger\Monolog, which extends the Monolog logger. That logger has handlers for var/log/system.log, var/log/debug.log and var/log/exception.log, among others.

The handler for files is Magento\Framework\Logger\Handler\Base. It takes a file name, writes through Magento's filesystem driver and has a minimum level. Since both the logger and the handler are normal classes created by the object manager, you can build new combinations of them in di.xml.

A custom log file with virtual types

For a module Vendor_Shipping that calls an external rates API, add this to etc/di.xml:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">

    <virtualType name="Vendor\Shipping\Logger\Handler" type="Magento\Framework\Logger\Handler\Base">
        <arguments>
            <argument name="fileName" xsi:type="string">/var/log/vendor_shipping.log</argument>
        </arguments>
    </virtualType>

    <virtualType name="Vendor\Shipping\Logger\Logger" type="Magento\Framework\Logger\Monolog">
        <arguments>
            <argument name="name" xsi:type="string">vendor_shipping</argument>
            <argument name="handlers" xsi:type="array">
                <item name="file" xsi:type="object">Vendor\Shipping\Logger\Handler</item>
            </argument>
        </arguments>
    </virtualType>

    <type name="Vendor\Shipping\Model\RateClient">
        <arguments>
            <argument name="logger" xsi:type="object">Vendor\Shipping\Logger\Logger</argument>
        </arguments>
    </type>
</config>

The file name is relative to the Magento root, so /var/log/vendor_shipping.log ends up in var/log of the installation, not in the system /var/log. The name argument is the channel, which appears in each log line and helps when several loggers write to the same place.

The class itself stays completely standard:

<?php
declare(strict_types=1);

namespace Vendor\Shipping\Model;

use Magento\Framework\HTTP\Client\Curl;
use Psr\Log\LoggerInterface;

class RateClient
{
    public function __construct(
        private readonly Curl $curl,
        private readonly LoggerInterface $logger
    ) {
    }

    public function fetchRates(string $postcode, float $weight): array
    {
        $this->logger->info('Requesting rates', ['postcode' => $postcode, 'weight' => $weight]);

        try {
            $this->curl->setTimeout(5);
            $this->curl->get('https://rates.example.com/v1/quote?' . http_build_query([
                'postcode' => $postcode,
                'weight' => $weight,
            ]));
        } catch (\Throwable $e) {
            $this->logger->error('Rates request failed', ['exception' => $e]);
            return [];
        }

        if ($this->curl->getStatus() !== 200) {
            $this->logger->warning('Unexpected rates response', [
                'status' => $this->curl->getStatus(),
                'body' => mb_substr($this->curl->getBody(), 0, 500),
            ]);
            return [];
        }

        return json_decode($this->curl->getBody(), true) ?: [];
    }
}

It depends on LoggerInterface, as any class should. Which file it writes to is a DI decision. In a unit test you pass a mock logger, and nothing else changes. The ideas behind this are explained in dependency injection and virtual types.

After changing di.xml, clear the generated metadata. In developer mode a cache:flush is usually enough. In production mode run setup:di:compile as part of the deploy.

Checking that it works

If nothing appears in the new file, check these in order. First, that the class really receives your logger: a plugin or a preference on the class may create it through a different path, and a typo in the class name in the type node fails silently. Second, that generated code was refreshed after the di.xml change. Third, that the PHP user can write to var/log. A quick way to confirm the wiring is to call the class from a small CLI command, run it, and look at the file.

Sharing the logger across classes

Add a type block for each class that should use the module log. If many classes need it, that list gets long, and that is fine. It is explicit and easy to search. Avoid a preference for LoggerInterface scoped to your module, since preferences are global and would redirect every module's logs.

A handler class when you need more

The virtual type cannot change the minimum level, because Base sets it in a protected property. If you want a file that only records warnings and above, or you want a different formatter, create a small handler class:

<?php
declare(strict_types=1);

namespace Vendor\Shipping\Logger;

use Magento\Framework\Logger\Handler\Base;
use Monolog\Logger;

class WarningHandler extends Base
{
    protected $fileName = '/var/log/vendor_shipping_errors.log';

    protected $loggerType = Logger::WARNING;
}

Then use it as an extra item in the handlers array of your logger virtual type. A record goes to every handler that accepts its level, so you get the full log in one file and only problems in another.

Magento 2.4.8 ships Monolog 3, where levels are also available as the Monolog\Level enum. The Logger level constants still exist for backward compatibility and are what the core handlers use, so the class above works. Check the core handlers in lib/internal/Magento/Framework/Logger/Handler of your version if you write anything more advanced.

Log levels in practice

  • debug: detailed traces for development. Noisy, should not be on in production for long.
  • info: normal events worth keeping, like an order exported with its external ID.
  • warning: something unexpected that the code handled, such as a retry or a missing optional field.
  • error: an operation failed and needs attention.
  • critical and above: the module or the store cannot work. Pair these with alerting.

The default debug.log handler only writes when debug logging is enabled, which is off by default in production mode. It can be switched with bin/magento setup:config:set --enable-debug-logging=true, but on production a dedicated file at info level is easier to manage.

Context, not string building

Pass data as the context array instead of concatenating it into the message:

$this->logger->info('Order exported', ['increment_id' => $incrementId, 'erp_id' => $erpId]);

The message stays constant, so you can search and count it. The context is written as JSON after the message by the default line formatter, which keeps values readable and greppable.

For exceptions, pass the exception object under the exception key. Monolog then writes the class, message, file, line and trace. With the default system logger, records with an exception in their context go to exception.log. With your own logger they go to your file, which is usually what you want for an integration.

What not to log

  • Card data, CVV, passwords, OAuth tokens or API keys. Mask them or leave them out.
  • Full customer records and addresses unless there is a clear need and a retention policy.
  • Entire request and response bodies of large APIs at info level. Log a truncated body on errors only.

Logs are copied to backups, shared in tickets and read by people who should not see personal data. Treat them as data that will leak one day.

Rotation and size

Magento does not rotate its log files. A chatty integration can fill a disk in weeks. Use the system logrotate:

/var/www/shop/shared/var/log/*.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    copytruncate
}

copytruncate lets PHP keep writing to the same open file. Adjust the path to your layout. In a release based deploy, logs live in the shared var folder.

Reading logs while debugging

A dedicated file makes it easy to follow one integration in real time with tail -f var/log/vendor_shipping.log, or to filter by context values with grep. When logs are not enough, step through the code with a debugger. The setup is in Magento 2 Xdebug debugging. For integrations with external systems, see also ERP integration patterns, where logging and retries go together.

FAQ

How do I log to a custom file in Magento 2 without creating classes?

Declare a virtual type of Magento\Framework\Logger\Handler\Base with a fileName argument, a virtual type of Magento\Framework\Logger\Monolog that uses it, and inject that logger into your class through di.xml.

Why is my debug message not in debug.log?

Debug logging is disabled by default in production mode. It can be enabled with setup:config:set --enable-debug-logging=true, but on production prefer writing to your own log file at info level or above.

Can I use ObjectManager to create a logger quickly?

It works but it is the wrong habit. Inject Psr\Log\LoggerInterface in the constructor and point it at your logger in di.xml. The class stays testable and the log target can change without touching code.

Where do exceptions logged with the default logger go?

With the default system logger, records that carry an exception in the context are written to var/log/exception.log. Other errors go to var/log/system.log.

I am available for freelance Magento and Adobe Commerce work, including integrations that need good logging. If you need help with this, get in touch.

Related articles