Add Discord error logger channel
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace App\Logging;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Monolog\Handler\AbstractProcessingHandler;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
use Stringable;
|
||||
use Throwable;
|
||||
|
||||
class DiscordWebhookHandler extends AbstractProcessingHandler
|
||||
{
|
||||
private const string TRUNCATION_SUFFIX = "\n...(truncated)";
|
||||
|
||||
public function __construct(
|
||||
private readonly ?string $webhookUrl,
|
||||
Level|string|int $level = 'error',
|
||||
bool $bubble = true,
|
||||
private readonly ?string $mention = null,
|
||||
private readonly string $title = '## Laravel error',
|
||||
private readonly float $timeout = 3.0,
|
||||
private readonly float $connectTimeout = 3.0,
|
||||
private readonly int $maxContentLength = 2000,
|
||||
private readonly int $maxTraceLines = 12,
|
||||
private readonly int $maxContextLength = 1200,
|
||||
) {
|
||||
parent::__construct($level, $bubble);
|
||||
}
|
||||
|
||||
protected function write(LogRecord $record): void
|
||||
{
|
||||
if ($this->webhookUrl === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::asJson()
|
||||
->connectTimeout($this->connectTimeout)
|
||||
->timeout($this->timeout)
|
||||
->post($this->webhookUrl, [
|
||||
'content' => $this->formatContent($record),
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
error_log(sprintf(
|
||||
'[discord logger] webhook failed with status %d: %s',
|
||||
$response->status(),
|
||||
$this->truncateForDiscord($response->body())
|
||||
));
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
error_log(sprintf('[discord logger] %s', $exception->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private function formatContent(LogRecord $record): string
|
||||
{
|
||||
$lines = [];
|
||||
|
||||
if ($this->mention !== null) {
|
||||
$lines[] = $this->mention;
|
||||
}
|
||||
|
||||
$lines[] = $this->title;
|
||||
$lines[] = sprintf('- App: %s (%s)', (string) config('app.name'), (string) config('app.env'));
|
||||
$lines[] = sprintf('- Level: %s', $record->level->getName());
|
||||
$lines[] = sprintf('- Time: %s', $record->datetime->format(DATE_ATOM));
|
||||
$lines[] = sprintf('- Message: %s', $record->message);
|
||||
|
||||
/** @var Throwable|null $exception */
|
||||
$exception = Arr::get($record->context, 'exception');
|
||||
|
||||
if ($exception instanceof Throwable) {
|
||||
$lines[] = '';
|
||||
$lines[] = '**Exception**';
|
||||
$lines[] = sprintf('> %s', $exception->getMessage());
|
||||
$lines[] = sprintf('> %s', $exception::class);
|
||||
$lines[] = sprintf('> %s:%d', $exception->getFile(), $exception->getLine());
|
||||
|
||||
$trace = $this->normalizeTrace($exception);
|
||||
if ($trace !== []) {
|
||||
$lines[] = '```text';
|
||||
$lines[] = implode("\n", $trace);
|
||||
$lines[] = '```';
|
||||
}
|
||||
}
|
||||
|
||||
$context = Arr::except($record->context, ['exception']);
|
||||
if ($context !== []) {
|
||||
$lines[] = '';
|
||||
$lines[] = '**Context**';
|
||||
$lines[] = '```json';
|
||||
$lines[] = $this->truncateContext($this->jsonEncode($this->normalizeValue($context)));
|
||||
$lines[] = '```';
|
||||
}
|
||||
|
||||
return $this->truncateForDiscord(implode("\n", $lines));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeTrace(Throwable $exception): array
|
||||
{
|
||||
return array_map(
|
||||
fn (string $line): string => $this->truncateLine($line, 240),
|
||||
array_slice(explode("\n", $exception->getTraceAsString()), 0, $this->maxTraceLines),
|
||||
);
|
||||
}
|
||||
|
||||
private function truncateLine(string $value, int $limit): string
|
||||
{
|
||||
if (mb_strlen($value) <= $limit) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return mb_substr($value, 0, max(0, $limit - 1)).'…';
|
||||
}
|
||||
|
||||
private function truncateContext(string $value): string
|
||||
{
|
||||
if (mb_strlen($value) <= $this->maxContextLength) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return mb_substr($value, 0, max(0, $this->maxContextLength - 1)).'…';
|
||||
}
|
||||
|
||||
private function truncateForDiscord(string $value): string
|
||||
{
|
||||
if (mb_strlen($value) <= $this->maxContentLength) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$limit = max(0, $this->maxContentLength - mb_strlen(self::TRUNCATION_SUFFIX));
|
||||
|
||||
return mb_substr($value, 0, $limit).self::TRUNCATION_SUFFIX;
|
||||
}
|
||||
|
||||
private function jsonEncode(mixed $value): string
|
||||
{
|
||||
$encoded = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR);
|
||||
|
||||
return is_string($encoded) ? $encoded : '{}';
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): mixed
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return array_map(fn (mixed $item): mixed => $this->normalizeValue($item), $value);
|
||||
}
|
||||
|
||||
if ($value instanceof Throwable) {
|
||||
return [
|
||||
'class' => $value::class,
|
||||
'message' => $value->getMessage(),
|
||||
'file' => $value->getFile(),
|
||||
'line' => $value->getLine(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($value instanceof Stringable) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
if (is_object($value)) {
|
||||
return ['class' => $value::class];
|
||||
}
|
||||
|
||||
if (is_resource($value)) {
|
||||
return sprintf('resource(%s)', get_resource_type($value));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user