Merge pull request 'Add Discord error logger channel' (#78) from koda/137-discord-error-logger into main
Reviewed-on: OpenSource/KanbanGit#78
This commit was merged in pull request #78.
This commit is contained in:
@@ -12,6 +12,15 @@ APP_FAKER_LOCALE=en_US
|
||||
|
||||
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_HOST=kanbangit-db
|
||||
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
|
||||
|
||||
use App\Logging\DiscordLogger;
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
@@ -82,6 +83,20 @@ return [
|
||||
'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' => [
|
||||
'driver' => 'monolog',
|
||||
'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