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

**URL:** https://forums.concretecms.org/t/per-site-error-logs-and-a-daily-digest-that-knows-a-bug-from-a-scanner/10047
**Category:** Developing with Concrete
**Created:** [September 16, 2026, 9:00pm UTC](https://forums.concretecms.org/t/per-site-error-logs-and-a-daily-digest-that-knows-a-bug-from-a-scanner/10047 "2026-09-16T21:00:37Z")
**Posts on this page:** 3
**Page:** 1

<div class="post-metadata">

### Author: ![jans](https://forums.concretecms.org/user_avatar/forums.concretecms.org/jans/32/5462_2.png) [@jans](https://forums.concretecms.org/u/jans)
#### Post date: [September 16, 2026, 9:00pm UTC](https://forums.concretecms.org/t/per-site-error-logs-and-a-daily-digest-that-knows-a-bug-from-a-scanner/10047/1 "2026-09-16T21:00:37Z")

</div>

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:

```php
'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:

```php
$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:

```auto
"} {"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:

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

```

[The script](https://gist.github.com/janscarton/d5c6e9d9f9fe658e3bbe005a3eb06858) 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.

---

<div class="post-metadata">

### Author: ![mlocati](https://forums.concretecms.org/user_avatar/forums.concretecms.org/mlocati/32/6208_2.png) [@mlocati](https://forums.concretecms.org/u/mlocati)
#### Post date: [September 17, 2026, 10:10am UTC](https://forums.concretecms.org/t/per-site-error-logs-and-a-daily-digest-that-knows-a-bug-from-a-scanner/10047/2 "2026-09-17T10:10:16Z")

</div>

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

> **[Error Notifier](https://market.concretecms.com/products/a3e557a6-d743-11ee-b9df-0a97d4ce16b9)**
>
> Real-time error notifications directly to Slack or Telegram

---

<div class="post-metadata">

### Author: ![jans](https://forums.concretecms.org/user_avatar/forums.concretecms.org/jans/32/5462_2.png) [@jans](https://forums.concretecms.org/u/jans)
#### Post date: [September 17, 2026, 6:31pm UTC](https://forums.concretecms.org/t/per-site-error-logs-and-a-daily-digest-that-knows-a-bug-from-a-scanner/10047/3 "2026-09-17T18:31:07Z")

</div>

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.
