Add Discord error logger channel
This commit is contained in:
@@ -12,6 +12,15 @@ APP_FAKER_LOCALE=en_US
|
|||||||
|
|
||||||
LOG_STACK=daily
|
LOG_STACK=daily
|
||||||
|
|
||||||
|
# Optional Discord error logging (append `,discord_errors` to LOG_STACK to enable it)
|
||||||
|
LOG_DISCORD_WEBHOOK_URL=
|
||||||
|
LOG_DISCORD_LEVEL=error
|
||||||
|
LOG_DISCORD_MENTION=
|
||||||
|
LOG_DISCORD_TITLE="## kanbangit error"
|
||||||
|
LOG_DISCORD_TIMEOUT=3
|
||||||
|
LOG_DISCORD_CONNECT_TIMEOUT=3
|
||||||
|
LOG_DISCORD_MAX_TRACE_LINES=12
|
||||||
|
|
||||||
DB_CONNECTION=mariadb
|
DB_CONNECTION=mariadb
|
||||||
DB_HOST=kanbangit-db
|
DB_HOST=kanbangit-db
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Logging;
|
||||||
|
|
||||||
|
use Monolog\Logger;
|
||||||
|
|
||||||
|
class DiscordLogger
|
||||||
|
{
|
||||||
|
public function __invoke(array $config): Logger
|
||||||
|
{
|
||||||
|
$logger = new Logger((string) ($config['name'] ?? 'discord-errors'));
|
||||||
|
|
||||||
|
$logger->pushHandler(new DiscordWebhookHandler(
|
||||||
|
webhookUrl: $this->nullableString($config['url'] ?? null),
|
||||||
|
level: $config['level'] ?? 'error',
|
||||||
|
bubble: (bool) ($config['bubble'] ?? true),
|
||||||
|
mention: $this->nullableString($config['mention'] ?? null),
|
||||||
|
title: (string) ($config['title'] ?? '## Laravel error'),
|
||||||
|
timeout: $this->floatValue($config['timeout'] ?? 3),
|
||||||
|
connectTimeout: $this->floatValue($config['connect_timeout'] ?? 3),
|
||||||
|
maxContentLength: $this->intValue($config['max_content_length'] ?? 2000, 2000),
|
||||||
|
maxTraceLines: $this->intValue($config['max_trace_lines'] ?? 12, 12),
|
||||||
|
maxContextLength: $this->intValue($config['max_context_length'] ?? 1200, 1200),
|
||||||
|
));
|
||||||
|
|
||||||
|
return $logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableString(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
return $value === '' ? null : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function floatValue(mixed $value): float
|
||||||
|
{
|
||||||
|
return is_numeric($value) && (float) $value > 0 ? (float) $value : 3.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function intValue(mixed $value, int $fallback): int
|
||||||
|
{
|
||||||
|
return is_numeric($value) && (int) $value > 0 ? (int) $value : $fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Logging\DiscordLogger;
|
||||||
use Monolog\Handler\NullHandler;
|
use Monolog\Handler\NullHandler;
|
||||||
use Monolog\Handler\StreamHandler;
|
use Monolog\Handler\StreamHandler;
|
||||||
use Monolog\Handler\SyslogUdpHandler;
|
use Monolog\Handler\SyslogUdpHandler;
|
||||||
@@ -82,6 +83,20 @@ return [
|
|||||||
'replace_placeholders' => true,
|
'replace_placeholders' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'discord_errors' => [
|
||||||
|
'driver' => 'custom',
|
||||||
|
'via' => DiscordLogger::class,
|
||||||
|
'url' => env('LOG_DISCORD_WEBHOOK_URL'),
|
||||||
|
'level' => env('LOG_DISCORD_LEVEL', 'error'),
|
||||||
|
'mention' => env('LOG_DISCORD_MENTION'),
|
||||||
|
'title' => env('LOG_DISCORD_TITLE', sprintf('## %s error', env('APP_NAME', 'Laravel'))),
|
||||||
|
'timeout' => env('LOG_DISCORD_TIMEOUT', 3),
|
||||||
|
'connect_timeout' => env('LOG_DISCORD_CONNECT_TIMEOUT', 3),
|
||||||
|
'max_content_length' => env('LOG_DISCORD_MAX_CONTENT_LENGTH', 2000),
|
||||||
|
'max_trace_lines' => env('LOG_DISCORD_MAX_TRACE_LINES', 12),
|
||||||
|
'max_context_length' => env('LOG_DISCORD_MAX_CONTEXT_LENGTH', 1200),
|
||||||
|
],
|
||||||
|
|
||||||
'papertrail' => [
|
'papertrail' => [
|
||||||
'driver' => 'monolog',
|
'driver' => 'monolog',
|
||||||
'level' => env('LOG_LEVEL', 'debug'),
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Logging;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use RuntimeException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class DiscordLoggerTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Log::forgetChannel('discord_errors');
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the Discord error logger posts a formatted exception payload.
|
||||||
|
*/
|
||||||
|
public function test_discord_error_logger_posts_a_formatted_exception_payload(): void
|
||||||
|
{
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
Http::fake([
|
||||||
|
'https://discord.example.test/webhook' => Http::response('', 204),
|
||||||
|
]);
|
||||||
|
|
||||||
|
config([
|
||||||
|
'logging.channels.discord_errors.url' => 'https://discord.example.test/webhook',
|
||||||
|
'logging.channels.discord_errors.mention' => '@here',
|
||||||
|
'logging.channels.discord_errors.title' => '## KanbanGit error',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::forgetChannel('discord_errors');
|
||||||
|
Log::channel('discord_errors')->error('Unhandled exception while syncing board', [
|
||||||
|
'exception' => new RuntimeException('Discord logger boom'),
|
||||||
|
'project' => 'CORE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::assertSentCount(1);
|
||||||
|
Http::assertSent(function (Request $request): bool {
|
||||||
|
$content = $request->data()['content'] ?? '';
|
||||||
|
|
||||||
|
return $request->method() === 'POST'
|
||||||
|
&& $request->url() === 'https://discord.example.test/webhook'
|
||||||
|
&& $request->hasHeader('Content-Type', 'application/json')
|
||||||
|
&& str_contains($content, '@here')
|
||||||
|
&& str_contains($content, '## KanbanGit error')
|
||||||
|
&& str_contains($content, 'Unhandled exception while syncing board')
|
||||||
|
&& str_contains($content, 'Discord logger boom')
|
||||||
|
&& str_contains($content, 'RuntimeException')
|
||||||
|
&& str_contains($content, '"project": "CORE"');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the Discord error logger becomes a no-op when no webhook is configured.
|
||||||
|
*/
|
||||||
|
public function test_discord_error_logger_skips_delivery_without_a_webhook(): void
|
||||||
|
{
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
config([
|
||||||
|
'logging.channels.discord_errors.url' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::forgetChannel('discord_errors');
|
||||||
|
Log::channel('discord_errors')->error('This should stay local');
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the Discord error logger truncates oversized payloads safely.
|
||||||
|
*/
|
||||||
|
public function test_discord_error_logger_truncates_oversized_payloads(): void
|
||||||
|
{
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
Http::fake([
|
||||||
|
'https://discord.example.test/webhook' => Http::response('', 204),
|
||||||
|
]);
|
||||||
|
|
||||||
|
config([
|
||||||
|
'logging.channels.discord_errors.url' => 'https://discord.example.test/webhook',
|
||||||
|
'logging.channels.discord_errors.max_content_length' => 180,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::forgetChannel('discord_errors');
|
||||||
|
Log::channel('discord_errors')->error(str_repeat('A', 600));
|
||||||
|
|
||||||
|
Http::assertSent(function (Request $request): bool {
|
||||||
|
$content = $request->data()['content'] ?? '';
|
||||||
|
|
||||||
|
return mb_strlen($content) <= 180
|
||||||
|
&& str_ends_with($content, '...(truncated)');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user