Lines 100.00% 23 / 23
Functions and Methods 100.00% 5 / 5
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
ChatLogHandler 100.00% 23 / 23 100.00% 5 / 5 13 100.00% 1 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 handle 100.00% 2 / 2 100.00% 1 / 1 1
 handleBatch 100.00% 12 / 12 100.00% 1 / 1 6
 isPosted 100.00% 1 / 1 100.00% 1 / 1 2
 line 100.00% 7 / 7 100.00% 1 / 1 3
1<?php
2
3declare(strict_types=1);
4
5namespace App\Log;
6
7use App\Notifier\ChatFeed;
8use App\Notifier\ChatSender;
9use App\Notifier\ChatText;
10use Monolog\Handler\AbstractHandler;
11use Monolog\Level;
12use Monolog\LogRecord;
13use Symfony\Component\HttpFoundation\Request;
14use Symfony\Component\HttpFoundation\RequestStack;
15
16/**
17 * Posts errors and deprecations to Slack and Telegram (ChatFeed::Errors), one message per batch.
18 * Used in dev and prod behind Monolog's deduplication handlers (config/packages/monolog.yaml),
19 * which pass on whole buffers: only records of level error and up and those of the deprecation
20 * channel are posted. A failure to post goes to PHP's error log, not to Monolog, so it cannot
21 * come back here.
22 */
23final class ChatLogHandler extends AbstractHandler
24{
25    private bool $sending = false;
26
27    public function __construct(
28        private readonly ChatSender $chatSender,
29        private readonly RequestStack $requestStack,
30    ) {
31        parent::__construct(Level::Debug, false);
32    }
33
34    public function handle(LogRecord $record): bool
35    {
36        $this->handleBatch([$record]);
37
38        return false;
39    }
40
41    /**
42     * @param array<LogRecord> $records
43     */
44    public function handleBatch(array $records): void
45    {
46        $posted = \array_filter($records, $this->isPosted(...));
47        if ([] === $posted || $this->sending) {
48            return;
49        }
50
51        $text = new ChatText();
52        foreach ($posted as $record) {
53            $this->line($text, $record);
54        }
55
56        $request = $this->requestStack->getMainRequest();
57        $text->text($request instanceof Request ? $request->getMethod().' '.$request->getUri() : 'console');
58
59        $this->sending = true;
60        try {
61            foreach ($this->chatSender->send(ChatFeed::Errors, $text) as $transport => $exception) {
62                \error_log(\sprintf('%s: a log message was not sent: %s', $transport, $exception->getMessage()));
63            }
64        } finally {
65            $this->sending = false;
66        }
67    }
68
69    private function isPosted(LogRecord $record): bool
70    {
71        return $record->level->isHigherThan(Level::Warning) || 'deprecation' === $record->channel;
72    }
73
74    private function line(ChatText $text, LogRecord $record): void
75    {
76        $text->text('deprecation' === $record->channel ? '⚠️ ' : '🚨 ')
77            ->bold($record->level->getName())
78            ->text(\sprintf(' %s: %s', $record->channel, $record->message));
79
80        $exception = $record->context['exception'] ?? null;
81        if ($exception instanceof \Throwable) {
82            $text->text("\n")->code($exception::class)->text(\sprintf(' at %s:%d', $exception->getFile(), $exception->getLine()));
83        }
84
85        $text->text("\n");
86    }
87}