phpsymfonymonolog

How to do advanced filtering of Monolog messages in Symfony?


I am using the MonologBundle in my Symfony 2.8 project to manage log messages. Using different Handlers it is no problem to write logs to file and to send them by e-mail at the same time.

I would like to reduce the number of messages I receive by mail. I already use the DeduplicationHandler and the FingersCrossed handler to filter by error level and to avoid duplicate messages. This works fine but is not enough.

For example I would like to reduce the number of mail about PageNotFound errors. Of course I want to be notified if /existingPage is not found, but I am not interested in messages about /.well-known/... files.

Another example are messages about errors in a third party CSV parser component. There are several known and harmless errors I am not interested in, but of course other errors are important.

This these errors/messages are generated by third party code, I cannot influence the source. I could only ignore these messages completely but this is not what I want.

I am looking for a solution to filter the messages by content. How can this be done in Monolog?

I already tried to solve this using a HandlerWrapper and discussed this issue in another question: The idea was, that the HandlerWrapper acts as filter. The HandlerWrapper is called by Monolog, it checks the message content and decides wether it should be processed or not (e.g. discard all messages including the text "./well-known/"). If a messages passes, the HandlerWrapper should simple hand it over to its nested/wrapped handler. Otherwise the message is skipped without further processing.

However this idea did not work, and the answers to the other question indicate, that a HandlerWrapper is not the right approach for this problem.

So the new/actual question is: How to create a filter for Monolog messages, that let me control wether a specific message should be process or not?


Solution

  • I'm not sure why using a HandlerWrapper is the wrong way to do it.

    I had the same issue and figured a way how to wrap a handler in order to filter certain records.

    In this answer I describe two ways to solve this, a more complex and an easy one.

    (More or less) complex way

    First thing I did, was to create a new class wich extends the HandlerWrapper and added some logic where I can filter records:

    use Monolog\Handler\HandlerWrapper;
    
    class CustomHandler extends HandlerWrapper
    {
        public function isHandling(array $record)
        {
            if ($this->shouldFilter($record)) {
                return false;
            }
    
            return $this->handler->isHandling($record);
        }
    
        public function handle(array $record)
        {
            if (!$this->isHandling($record)) {
                return false;
            }
    
            return $this->handler->handle($record);
        }
    
        public function handleBatch(array $records)
        {
            foreach ($records as $record) {
                $this->handle($record);
            }
        }
    
        private function shouldFilter(array $record)
        {
            return mt_rand(0, 1) === 1;; // add logic here
        }
    }
    

    Then I created a service definition and a CompilerPass where I can wrap the GroupHandler

    services.yml

    CustomHandler:
        class: CustomHandler
        abstract: true
        arguments: ['']
    
    use Monolog\Handler\GroupHandler;
    use Symfony\Component\DependencyInjection\ChildDefinition;
    use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
    use Symfony\Component\DependencyInjection\ContainerBuilder;
    use Symfony\Component\DependencyInjection\Definition;
    use Symfony\Component\DependencyInjection\Reference;
    
    class CustomMonologHandlerPass implements CompilerPassInterface
    {
        public function process(ContainerBuilder $container)
        {
            if (!$container->hasDefinition(CustomHandler::class)) {
                return;
            }
    
            $definitions = $container->getDefinitions();
            foreach ($definitions as $serviceId => $definition) {
                if (!$this->isValidDefinition($definition)) {
                    continue;
                }
    
                $cacheId = $serviceId . '.wrapper';
    
                $container
                    ->setDefinition($cacheId, new ChildDefinition(CustomHandler::class))
                    ->replaceArgument(0, new Reference($cacheId . '.inner'))
                    ->setDecoratedService($serviceId);
            }
        }
    
        private function isValidDefinition(Definition $definition): bool
        {
            return GroupHandler::class === $definition->getClass();
        }
    }
    

    As you can see I go over all definitions here and find the ones which have the GroupHandler set as their class. If this is the case, I add a new definition to the container which decorates the original handler with my CustomHandler.

    Side note: At first I tried to wrap all handlers (except the CustomHandler of course :)) but due to some handlers implementing other interfaces (like the ConsoleHandler using the EventSubscriberInterface) this did not work and lead to issues I didn't want to solve in some hacky way.

    Don't forget to add this compiler pass to the container in your AppBundle class

    class AppBundle extends Bundle
    {
        public function build(ContainerBuilder $container)
        {
            $container->addCompilerPass(new CustomMonologHandlerPass());
        }
    }
    

    Now that everything is in place you have to group your handlers in order to make this work:

    app/config(_prod|_dev).yml

    monolog:
        handlers:
            my_group:
                type: group
                members: [ 'graylog' ]
            graylog:
                type: gelf
                publisher:
                    id: my.publisher
                level: debug
                formatter: my.formatter
    

    Easy way

    We use the same CustomHandler as we did in the complex way, then we define our handlers in the config:

    app/config(_prod|_dev).yml

    monolog:
        handlers:
            graylog:
                type: gelf
                publisher:
                    id: my.publisher
                level: debug
                formatter: my.formatter
    

    Decorate the handler in your services.yml with your own CustomHandler

    services.yml

    CustomHandler:
        class: CustomHandler
        decorates: monolog.handler.graylog
        arguments: ['@CustomHandler.inner']
    

    For the decorates property you have to use the format monolog.handler.$NAME_SPECIFIED_AS_KEY_IN_CONFIG, in this case it was graylog.

    ... and thats it

    Summary

    While both ways work, I used the first one, as we have several symfony projects where I need this and decorating all handlers manually is just not what I wanted.

    I hope this helps (even though I'm quite late for an answer :))