Per-site error logs and a daily digest that knows a bug from a scanner

I run a couple dozen Concrete 9.5 sites and had reached the point where the Dashboard log page wasn’t cutting it. I wanted an email each morning that told me whether anything actually broke. The setup took three pieces, and the middle one turned out to be the tricky bit. It’s also where some of the bugs I’ve been sending upstream came from.

1. Log to a file per site

Concrete’s simple logging mode writes to the database only. Advanced mode hands you Monolog Cascade, so you can keep the database handler (the Dashboard page keeps working) and add a file handler that names the file after the host:

'log' => [
    'configuration' => [
        'mode' => 'advanced',
        'advanced' => [
            'configuration' => [
                'formatters' => [
                    'fileFormatter' => [
                        'class' => 'Monolog\Formatter\LineFormatter',
                        'includeStacktraces' => true,
                    ],
                ],
                'handlers' => [
                    'database' => [
                        'class' => 'Concrete\Core\Logging\Handler\DatabaseHandler',
                        'level' => 'ERROR',
                    ],
                    'file' => [
                        'class' => 'Monolog\Handler\StreamHandler',
                        'level' => 'ERROR',
                        'stream' => '/var/log/concrete/' . ($_SERVER['HTTP_HOST'] ?? 'concrete') . '.log',
                        'formatter' => 'fileFormatter',
                    ],
                ],
                'processors' => [
                    'web' => ['class' => 'Monolog\Processor\WebProcessor'],
                ],
                'loggers' => [
                    'all' => [
                        'handlers' => ['file', 'database'],
                        'processors' => ['web'],
                    ],
                ],
            ],
        ],
    ],
],

That goes in application/config/concrete.php. /var/log/concrete/ needs to exist and be writable by the web server user, and you’ll want a logrotate entry. WebProcessor gives you the URL, IP, and method on each entry, which is important later.

2. Get the user and page back

Here’s the tricky bit. Simple mode adds ConcreteUserProcessor and ConcretePageProcessor to the exceptions logger, so every entry says which user and which page. Advanced mode doesn’t. And you can’t add them from a package’s on_start() or from app.php, because ErrorHandlingServiceProvider builds the exceptions logger before either of those runs.

The only place early enough is application/bootstrap/start.php, by rebinding ConfigurationFactory to a decorator that pushes the two processors onto every logger Cascade creates:

$app->bind(
    \Concrete\Core\Logging\Configuration\ConfigurationFactory::class,
    function ($app) {
        return new class($app->make('config'), $app)
            extends \Concrete\Core\Logging\Configuration\ConfigurationFactory {
            public function createConfiguration()
            {
                $config = parent::createConfiguration();
                if ($config instanceof \Concrete\Core\Logging\Configuration\AdvancedConfiguration) {
                    $app = $this->app;
                    $config = new class($config, $app)
                        implements \Concrete\Core\Logging\Configuration\ConfigurationInterface {
                        private $inner;
                        private $app;
                        public function __construct($inner, $app)
                        {
                            $this->inner = $inner;
                            $this->app = $app;
                        }
                        public function createLogger($channel)
                        {
                            $logger = $this->inner->createLogger($channel);
                            $logger->pushProcessor($this->app->make(
                                \Concrete\Core\Logging\Processor\ConcreteUserProcessor::class
                            ));
                            $logger->pushProcessor($this->app->make(
                                \Concrete\Core\Logging\Processor\ConcretePageProcessor::class
                            ));
                            return $logger;
                        }
                    };
                }
                return $config;
            }
        };
    }
);

With that in place, an entry from a signed-in user ends like this:

"} {"page":[883,"Some Page"],"user":[12,"someeditor"],"url":"/ccm/system/dialogs/block/edit?cID=883...","ip":"...","http_method":"GET",...}

and an anonymous request has no user field at all. That one field turns out to be the most useful bit in the whole log.

3. A digest that sorts entries by what they are

The first version of my digest just counted entries per site. It was useless within a week, because on any given day most of the log is scanners. A couple things I learned from the raw files fixed things.

First, the log level tells you nothing. Concrete’s error handler logs every uncaught throwable at the single level set by concrete.error.handling.error.logLevel. It’s ERROR on most of my sites; a couple had EMERGENCY from an old config, and the same scanner hit shows up as exceptions.EMERGENCY there and exceptions.ERROR everywhere else. So don’t filter on level.

Second, the exception class in the trailing JSON is the real discriminator. Concrete\Core\Error\UserMessageException is Concrete’s deliberate “show this to the user” exception: Access Denied, Invalid path traversal, Unable to find the specified page, Invalid file. Everything else (Error, TypeError, plain Exception, Doctrine) is a genuine bug or outage. Across my sites over a week it ran about three user-facing exceptions for every real error.

Put the two together and you get three tiers:

  1. Real PHP errors. Any class other than UserMessageException. Reported in full: class, throwing file and line, first and last timestamp, up to three of the requests that triggered it, and how many distinct IPs.

  2. Editor problems. A UserMessageException with a user field. A signed-in person hit a wall. Same full treatment, plus the username and the page they were on. Ordinary visitors almost never trigger these, because they come from ccm/system dialogs and panels that nothing public links to, so this tier is usually empty and worth reading when it isn’t.

  3. Anonymous messages. A UserMessageException with no user field. Almost always a scanner. One count line per site and message so an odd message or a big number still shows, but nothing is ever dropped.

There’s deliberately no “known noise” list. I had one for a few days and it was redundant once the class check existed, and a message-text filter is the one place a digest can make a real error disappear. The subject line carries the three counts, so the inbox view alone tells me whether to open it:

Concrete errors — 1 real, 0 editor, 86 anonymous — example.com

The script is bash plus awk, parses each Monolog entry as a unit (header line, stack trace, trailing JSON), and runs the same on BSD awk and gawk. It reads /var/log/concrete/*.log and the most recent rotated file, runs from cron at 07:05, and has --stdout plus LOGDIR and SINCE overrides for testing against a copy of your logs.

What the logs turned up

The kind of thing you see once the file logs are in place, and wouldn’t from the Dashboard: /ccm/system/dialogs/file/properties?fID= with an empty or junk fID throws Call to a member function canViewFileInFileManager() on null and returns a 500 where a 404 or “Invalid file” belongs. Scanners fuzz that parameter, so a single IP can put sixty of these in a log in an afternoon, and every one is a stack trace instead of a one-liner. That one’s an open issue and PR now, and there are others in the queue that started the same way, as a line in one of these files.

If you run more than a handful of sites, the file handler alone is worth the twenty minutes. The start.php override is the part I wish someone had told me about.

If you just wanted to receive notifications when some error occurs, what about simply using this package?

Hi Michele,

I checked out your Error Notifier. It looks nice and hooks the right bits, but doesn’t fit my needs:

  • I don’t use Slack or Telegram.
  • I don’t want constant noise. This week’s one real bug fired 87 times on one site in two days. That’s one line in the digest, but 87 pings from a notifier.
  • The digest covers over two dozen sites from one place, with no per-site installs to keep updated.