Add Discord error logger channel #78

Merged
Twyco merged 1 commits from koda/137-discord-error-logger into main 2026-05-22 23:26:06 +00:00
5 changed files with 352 additions and 0 deletions
+9
View File
@@ -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
+49
View File
@@ -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;
}
}
+178
View File
@@ -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;
}
}
+15
View File
@@ -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'),
+101
View File
@@ -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)');
});
}
}