From 0052884ac87001c08e001ad87d37a61788a4a063 Mon Sep 17 00:00:00 2001 From: Koda Date: Fri, 22 May 2026 13:22:05 +0000 Subject: [PATCH] Add Discord error logger channel --- .env.example | 9 + app/Logging/DiscordLogger.php | 49 ++++++ app/Logging/DiscordWebhookHandler.php | 178 ++++++++++++++++++++ config/logging.php | 15 ++ tests/Feature/Logging/DiscordLoggerTest.php | 101 +++++++++++ 5 files changed, 352 insertions(+) create mode 100644 app/Logging/DiscordLogger.php create mode 100644 app/Logging/DiscordWebhookHandler.php create mode 100644 tests/Feature/Logging/DiscordLoggerTest.php diff --git a/.env.example b/.env.example index f44b3bc..382e811 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/Logging/DiscordLogger.php b/app/Logging/DiscordLogger.php new file mode 100644 index 0000000..b8fcbad --- /dev/null +++ b/app/Logging/DiscordLogger.php @@ -0,0 +1,49 @@ +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; + } +} diff --git a/app/Logging/DiscordWebhookHandler.php b/app/Logging/DiscordWebhookHandler.php new file mode 100644 index 0000000..91b9f90 --- /dev/null +++ b/app/Logging/DiscordWebhookHandler.php @@ -0,0 +1,178 @@ +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 + */ + 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; + } +} diff --git a/config/logging.php b/config/logging.php index b09cb25..5e1a752 100644 --- a/config/logging.php +++ b/config/logging.php @@ -1,5 +1,6 @@ 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'), diff --git a/tests/Feature/Logging/DiscordLoggerTest.php b/tests/Feature/Logging/DiscordLoggerTest.php new file mode 100644 index 0000000..c633d2b --- /dev/null +++ b/tests/Feature/Logging/DiscordLoggerTest.php @@ -0,0 +1,101 @@ + 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)'); + }); + } +}