I just shipped a new signal in Watchtower, my Magento monitoring tool:
application_errors, which watches for a rising rate of caught exceptions
your own code is logging, before that shows up anywhere else, like checkout.
The feature itself is simple. Getting there across three supported Magento
versions was not, and the two bugs I hit only showed up under live
verification, not unit tests. Here's what happened.
The goal: count exceptions without reading a log file
Every existing signal in my connector observes Magento's event bus or polls
a status table. This one is different: I wanted to know when a store's own
code starts throwing more than usual, at the point Magento's logger actually
logs it.
The obvious approach is tailing exception.log. I rejected it early: log
rotation, file permissions, and offset tracking are all real failure modes,
and none of them should ever be able to produce a false anomaly. Instead I
hook Magento's own Monolog handler stack directly, via di.xml, so I
intercept each log record in memory at the moment it's dispatched, the same
moment Magento's own Handler\System and Handler\Exception do. I never
open, read, or track an offset into any file. Whatever happens to the
physical log afterward has zero effect on the counter.
Bug 1: Monolog 2 vs Monolog 3
Magento 2.4.7 ships Monolog 2.x, where a handler's $record is a plain
array. Magento 2.4.8 and 2.4.9 ship Monolog 3.x, where it's an immutable
LogRecord object. HandlerInterface's method signatures are incompatible
between the two majors, so one PHP class cannot implement both.
My first attempt was the obvious one: two concrete classes, class_alias()'d
to a common name at runtime depending on which Monolog major was loaded.
That worked fine under normal autoloading. It fataled under
bin/magento setup:di:compile.
The reason: Magento's compiler eagerly requires every class file in every
module for reflection (repository/proxy/interceptor generation), regardless
of whether anything actually references that class. Both concrete handler
files declared a top-level class ... implements HandlerInterface, and PHP
interface-checks a top-level class declaration the moment its file is
parsed. So the "wrong" file, whichever Monolog major wasn't installed where
compilation ran, always fataled immediately. The class_alias() gate never
got a chance to run, because compilation never went through it at all.
The fix: put both implementations in one file, each declared inside its own
branch of a runtime
if (class_exists(\Monolog\LogRecord::class)) { class Handler ... } else { class Handler ... }.
PHP hoists and interface-checks a top-level class statement immediately,
but a class declared inside a conditional block is bound only when that
branch actually executes. The untaken branch is never checked against
whichever HandlerInterface happens to be loaded, compiler included. Both
branches declare the literal same class name, so di.xml's reference
resolves correctly either way, no alias needed.
Bug 2: di.xml doesn't merge the way you'd expect
Magento's app/etc/di.xml is the primary bootstrap configuration, and it
already registers three core handlers on
Magento\Framework\Logger\Monolog: system, debug, syslog. My first
working version of the module's own di.xml declared only my new handler as
a fourth array item, assuming Magento would merge it into the existing three
by item name, the normal behavior for a per-module di.xml.
It doesn't, at this specific precedence boundary. A module-level
<argument name="handlers"> replaces app/etc/di.xml's array wholesale
rather than merging into it. My first version silently dropped system,
debug, and syslog. I confirmed it live with
Monolog\Logger::getHandlers(): one handler instead of four. That would
have disabled exception.log, system.log, and debug.log for every
install running this version.
No unit test caught it. PHPStan didn't catch it. What caught the bug was
running the module against a real Magento install and counting handlers,
not trusting a green test suite. Fixed by explicitly redeclaring all four
items.
What shipped
application_errors is now my 10th tracked signal: install-scoped (a
logged exception has no reliable store-view attribution), one-directional
(a quiet hour is never anomalous, only a spike is), and classified with the
same median/MAD dispersion baseline my three rate-based signals already
use. It only counts records carrying exception context at Warning level or
above, mirroring the same condition Magento core's own Handler\System
uses to route a record to exception.log in the first place. It never
reads the exception message or stack trace, count only.
Both bugs here were invisible to static analysis and to a green PHPUnit
suite. The thing that found them was doing the boring thing: installing the
actual module on actual Magento 2.4.7, 2.4.8, and 2.4.9 containers and
checking what really happened.
Top comments (0)