Files
KanbanGit/app/Logging/DiscordWebhookHandler.php
2026-05-22 13:22:05 +00:00

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;
}
}