179 lines
5.5 KiB
PHP
179 lines
5.5 KiB
PHP
<?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;
|
|
}
|
|
}
|