diff --git a/README.md b/README.md index a28a463..7ef8207 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Automatically create GitHub issues from your Laravel exceptions & logs. Perfect - 🏷️ Support customizable labels - 🎯 Smart deduplication to prevent issue spam - ⚡️ Buffered logging for better performance +- 📝 Customizable issue templates ## Showcase @@ -103,7 +104,29 @@ Log::stack(['daily', 'github'])->error('Something went wrong!'); ## Advanced Configuration -Deduplication and buffering are enabled by default to enhance logging. Customize these features to suit your needs. +### Customizing Templates + +The package uses Markdown templates to format issues and comments. You can customize these templates by publishing them: + +```bash +php artisan vendor:publish --tag="github-monolog-views" +``` + +This will copy the templates to `resources/views/vendor/github-monolog/` where you can modify them: + +- `issue.md`: Template for new issues +- `comment.md`: Template for comments on existing issues +- `previous_exception.md`: Template for previous exceptions in the chain + +Available template variables: +- `{level}`: Log level (error, warning, etc.) +- `{message}`: The error message or log content +- `{simplified_stack_trace}`: A cleaned up stack trace +- `{full_stack_trace}`: The complete stack trace +- `{previous_exceptions}`: Details of any previous exceptions +- `{context}`: Additional context data +- `{extra}`: Extra log data +- `{signature}`: Internal signature used for deduplication ### Deduplication diff --git a/composer.json b/composer.json index e21241c..928a9d1 100644 --- a/composer.json +++ b/composer.json @@ -64,6 +64,13 @@ "phpstan/extension-installer": true } }, + "extra": { + "laravel": { + "providers": [ + "Naoray\\LaravelGithubMonolog\\GithubMonologServiceProvider" + ] + } + }, "minimum-stability": "dev", "prefer-stable": true } diff --git a/resources/views/comment.md b/resources/views/comment.md new file mode 100644 index 0000000..36986d3 --- /dev/null +++ b/resources/views/comment.md @@ -0,0 +1,28 @@ +# New Occurrence + +**Log Level:** {level} + +{message} + +**Simplified Stack Trace:** +```php +{simplified_stack_trace} +``` + +
+Complete Stack Trace + +```php +{full_stack_trace} +``` +
+ +
+Previous Exceptions + +{previous_exceptions} +
+ +{context} + +{extra} diff --git a/resources/views/issue.md b/resources/views/issue.md new file mode 100644 index 0000000..fcd6903 --- /dev/null +++ b/resources/views/issue.md @@ -0,0 +1,28 @@ +**Log Level:** {level} + +{message} + +**Simplified Stack Trace:** +```php +{simplified_stack_trace} +``` + +
+Complete Stack Trace + +```php +{full_stack_trace} +``` +
+ +
+Previous Exceptions + +{previous_exceptions} +
+ +{context} + +{extra} + + diff --git a/resources/views/previous_exception.md b/resources/views/previous_exception.md new file mode 100644 index 0000000..cebdf93 --- /dev/null +++ b/resources/views/previous_exception.md @@ -0,0 +1,15 @@ +### Previous Exception #{count} +**Type:** {type} + +**Simplified Stack Trace:** +```php +{simplified_stack_trace} +``` + +
+Complete Stack Trace + +```php +{full_stack_trace} +``` +
diff --git a/src/GithubIssueHandlerFactory.php b/src/GithubIssueHandlerFactory.php index 4d81fab..2ac29f8 100644 --- a/src/GithubIssueHandlerFactory.php +++ b/src/GithubIssueHandlerFactory.php @@ -9,11 +9,15 @@ use Naoray\LaravelGithubMonolog\Deduplication\DeduplicationHandler; use Naoray\LaravelGithubMonolog\Deduplication\DefaultSignatureGenerator; use Naoray\LaravelGithubMonolog\Deduplication\SignatureGeneratorInterface; -use Naoray\LaravelGithubMonolog\Issues\Formatter; +use Naoray\LaravelGithubMonolog\Issues\Formatters\IssueFormatter; use Naoray\LaravelGithubMonolog\Issues\Handler; class GithubIssueHandlerFactory { + public function __construct( + private readonly IssueFormatter $formatter, + ) {} + public function __invoke(array $config): Logger { $this->validateConfig($config); @@ -45,7 +49,7 @@ protected function createBaseHandler(array $config): Handler bubble: Arr::get($config, 'bubble', true) ); - $handler->setFormatter(new Formatter); + $handler->setFormatter($this->formatter); return $handler; } diff --git a/src/GithubMonologServiceProvider.php b/src/GithubMonologServiceProvider.php new file mode 100644 index 0000000..6b86105 --- /dev/null +++ b/src/GithubMonologServiceProvider.php @@ -0,0 +1,46 @@ +app->bind(StackTraceFormatter::class); + $this->app->bind(StubLoader::class); + $this->app->bind(ExceptionFormatter::class, function ($app) { + return new ExceptionFormatter( + stackTraceFormatter: $app->make(StackTraceFormatter::class), + ); + }); + + $this->app->singleton(TemplateRenderer::class, function ($app) { + return new TemplateRenderer( + exceptionFormatter: $app->make(ExceptionFormatter::class), + stubLoader: $app->make(StubLoader::class), + ); + }); + + $this->app->singleton(IssueFormatter::class, function ($app) { + return new IssueFormatter( + templateRenderer: $app->make(TemplateRenderer::class), + ); + }); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__ . '/../resources/views' => resource_path('views/vendor/github-monolog'), + ], 'github-monolog-views'); + } + } +} diff --git a/src/Issues/Formatter.php b/src/Issues/Formatter.php index 5874f34..d36baab 100644 --- a/src/Issues/Formatter.php +++ b/src/Issues/Formatter.php @@ -1,59 +1,37 @@ extra['github_issue_signature'])) { + if (!isset($record->extra['github_issue_signature'])) { throw new \RuntimeException('Record is missing github_issue_signature in extra data. Make sure the DeduplicationHandler is configured correctly.'); } $exception = $this->getException($record); return new Formatted( - title: $this->formatTitle($record, $exception), - body: $this->formatBody($record, $record->extra['github_issue_signature'], $exception), - comment: $this->formatComment($record, $exception), + title: $this->templateRenderer->renderTitle($record, $exception), + body: $this->templateRenderer->render($this->templateRenderer->getIssueStub(), $record, $record->extra['github_issue_signature'], $exception), + comment: $this->templateRenderer->render($this->templateRenderer->getCommentStub(), $record, null, $exception), ); } - /** - * Formats a set of log records. - * - * @param array $records A set of records to format - * @return array The formatted set of records - */ public function formatBatch(array $records): array { return array_map([$this, 'format'], $records); } - /** - * Check if the record contains an error exception - */ private function hasErrorException(LogRecord $record): bool { return $record->level->value >= \Monolog\Level::Error->value @@ -61,231 +39,8 @@ private function hasErrorException(LogRecord $record): bool && $record->context['exception'] instanceof Throwable; } - /** - * Get the exception from the record if it exists - */ private function getException(LogRecord $record): ?Throwable { return $this->hasErrorException($record) ? $record->context['exception'] : null; } - - private function formatTitle(LogRecord $record, ?Throwable $exception = null): string - { - if (! $exception) { - return Str::of('[{level}] {message}') - ->replace('{level}', $record->level->getName()) - ->replace('{message}', Str::limit($record->message, self::TITLE_MAX_LENGTH)) - ->toString(); - } - - $exceptionClass = (new ReflectionClass($exception))->getShortName(); - $file = Str::replace(base_path(), '', $exception->getFile()); - - return Str::of('[{level}] {class} in {file}:{line} - {message}') - ->replace('{level}', $record->level->getName()) - ->replace('{class}', $exceptionClass) - ->replace('{file}', $file) - ->replace('{line}', (string) $exception->getLine()) - ->replace('{message}', Str::limit($exception->getMessage(), self::TITLE_MAX_LENGTH)) - ->toString(); - } - - private function formatContent(LogRecord $record, ?Throwable $exception): string - { - return Str::of('') - ->when($record->message, fn ($str, $message) => $str->append("**Message:**\n{$message}\n\n")) - ->when( - $exception, - function (Stringable $str, Throwable $exception) { - return $str->append( - $this->renderExceptionDetails($this->formatExceptionDetails($exception)), - $this->renderPreviousExceptions($this->formatPreviousExceptions($exception)) - ); - } - ) - ->when(! empty($record->context), fn ($str, $context) => $str->append("**Context:**\n```json\n".json_encode(Arr::except($record->context, ['exception']), JSON_PRETTY_PRINT)."\n```\n\n")) - ->when(! empty($record->extra), fn ($str, $extra) => $str->append("**Extra Data:**\n```json\n".json_encode($record->extra, JSON_PRETTY_PRINT)."\n```\n")) - ->toString(); - } - - private function formatBody(LogRecord $record, string $signature, ?Throwable $exception): string - { - return Str::of("**Log Level:** {$record->level->getName()}\n\n") - ->append($this->formatContent($record, $exception)) - ->append("\n\n") - ->toString(); - } - - /** - * Shamelessly stolen from Solo by @aarondfrancis - * - * See: https://github.com/aarondfrancis/solo/blob/main/src/Commands/EnhancedTailCommand.php - */ - private function cleanStackTrace(string $stackTrace): string - { - return collect(explode("\n", $stackTrace)) - ->filter(fn ($line) => ! empty(trim($line))) - ->map(function ($line) { - if (trim($line) === '"}') { - return ''; - } - - if (str_contains($line, '{"exception":"[object] ')) { - return $this->formatInitialException($line); - } - - // Not a stack frame line, return as is - if (! Str::isMatch('/#[0-9]+ /', $line)) { - return $line; - } - - // Make the line shorter by removing the base path - $line = str_replace(base_path(), '', $line); - - if (str_contains((string) $line, '/vendor/') && ! Str::isMatch("/BoundMethod\.php\([0-9]+\): App/", $line)) { - return self::VENDOR_FRAME_PLACEHOLDER; - } - - return $line; - }) - ->pipe($this->modifyWrappedLines(...)) - ->join("\n"); - } - - public function formatInitialException($line): array - { - [$message, $exception] = explode('{"exception":"[object] ', $line); - - return [ - $message, - $exception, - ]; - } - - protected function modifyWrappedLines(Collection $lines): Collection - { - $hasVendorFrame = false; - - // After all the lines have been wrapped, we look through them - // to collapse consecutive vendor frames into a single line. - return $lines->filter(function ($line) use (&$hasVendorFrame) { - $isVendorFrame = str_contains($line, '[Vendor frames]'); - - if ($isVendorFrame) { - // Skip the line if a vendor frame has already been added. - if ($hasVendorFrame) { - return false; - } - // Otherwise, mark that a vendor frame has been added. - $hasVendorFrame = true; - } else { - // Reset the flag if the current line is not a vendor frame. - $hasVendorFrame = false; - } - - return true; - }); - } - - private function formatExceptionDetails(Throwable $exception): array - { - $header = sprintf( - '[%s] %s: %s at %s:%d', - $this->getCurrentDateTime(), - (new ReflectionClass($exception))->getShortName(), - $exception->getMessage(), - str_replace(base_path(), '', $exception->getFile()), - $exception->getLine() - ); - - return [ - 'message' => $exception->getMessage(), - 'stack_trace' => $header."\n[stacktrace]\n".$this->cleanStackTrace($exception->getTraceAsString()), - 'full_stack_trace' => $header."\n[stacktrace]\n".$exception->getTraceAsString(), - ]; - } - - private function getCurrentDateTime(): string - { - return now()->format('Y-m-d H:i:s'); - } - - private function formatPreviousExceptions(Throwable $exception): array - { - $previous = $exception->getPrevious(); - if (! $previous) { - return []; - } - - return collect() - ->range(1, self::MAX_PREVIOUS_EXCEPTIONS) - ->map(function ($count) use (&$previous) { - if (! $previous) { - return null; - } - - $current = $previous; - $previous = $previous->getPrevious(); - - return [ - 'count' => $count, - 'type' => get_class($current), - 'details' => $this->formatExceptionDetails($current), - ]; - }) - ->filter() - ->values() - ->all(); - } - - private function renderExceptionDetails(array $details): string - { - $content = sprintf("**Simplified Stack Trace:**\n```php\n%s\n```\n\n", $details['stack_trace']); - - // Add the complete stack trace in details tag - $content .= "**Complete Stack Trace:**\n"; - $content .= "
\nView full trace\n\n"; - $content .= sprintf("```php\n%s\n```\n", str_replace(base_path(), '', $details['full_stack_trace'] ?? $details['stack_trace'])); - $content .= "
\n\n"; - - return $content; - } - - private function renderPreviousExceptions(array $exceptions): string - { - if (empty($exceptions)) { - return ''; - } - - $content = "
\nPrevious Exceptions\n\n"; - - foreach ($exceptions as $exception) { - $content .= "### Previous Exception #{$exception['count']}\n"; - $content .= "**Type:** {$exception['type']}\n\n"; - $content .= $this->renderExceptionDetails($exception['details']); - } - - if (count($exceptions) === self::MAX_PREVIOUS_EXCEPTIONS) { - $content .= "\n> Note: Additional previous exceptions were truncated\n"; - } - - $content .= "
\n\n"; - - return $content; - } - - /** - * Formats a log record for a comment on an existing issue. - * - * @param LogRecord $record A record to format - * @return string The formatted comment - */ - public function formatComment(LogRecord $record, ?Throwable $exception): string - { - $body = "# New Occurrence\n\n"; - $body .= "**Log Level:** {$record->level->getName()}\n\n"; - $body .= $this->formatContent($record, $exception); - - return $body; - } } diff --git a/src/Issues/Formatters/ExceptionFormatter.php b/src/Issues/Formatters/ExceptionFormatter.php new file mode 100644 index 0000000..726ab43 --- /dev/null +++ b/src/Issues/Formatters/ExceptionFormatter.php @@ -0,0 +1,66 @@ +context['exception'] ?? null; + if (!$exception instanceof Throwable) { + return []; + } + + $header = $this->formatHeader($exception); + $stackTrace = $exception->getTraceAsString(); + + return [ + 'message' => $exception->getMessage(), + 'simplified_stack_trace' => $header . "\n[stacktrace]\n" . $this->stackTraceFormatter->format($stackTrace), + 'full_stack_trace' => $header . "\n[stacktrace]\n" . $stackTrace, + ]; + } + + public function formatBatch(array $records): array + { + return array_map([$this, 'format'], $records); + } + + public function formatTitle(Throwable $exception, string $level): string + { + $exceptionClass = (new ReflectionClass($exception))->getShortName(); + $file = Str::replace(base_path(), '', $exception->getFile()); + + return Str::of('[{level}] {class} in {file}:{line} - {message}') + ->replace('{level}', $level) + ->replace('{class}', $exceptionClass) + ->replace('{file}', $file) + ->replace('{line}', (string) $exception->getLine()) + ->replace('{message}', Str::limit($exception->getMessage(), self::TITLE_MAX_LENGTH)) + ->toString(); + } + + private function formatHeader(Throwable $exception): string + { + return sprintf( + '[%s] %s: %s at %s:%d', + now()->format('Y-m-d H:i:s'), + (new ReflectionClass($exception))->getShortName(), + $exception->getMessage(), + str_replace(base_path(), '', $exception->getFile()), + $exception->getLine() + ); + } +} diff --git a/src/Issues/Formatted.php b/src/Issues/Formatters/Formatted.php similarity index 76% rename from src/Issues/Formatted.php rename to src/Issues/Formatters/Formatted.php index f84e647..22c24ea 100644 --- a/src/Issues/Formatted.php +++ b/src/Issues/Formatters/Formatted.php @@ -1,6 +1,6 @@ extra['github_issue_signature'])) { + throw new \RuntimeException('Record is missing github_issue_signature in extra data. Make sure the DeduplicationHandler is configured correctly.'); + } + + $exception = $this->getException($record); + + return new Formatted( + title: $this->templateRenderer->renderTitle($record, $exception), + body: $this->templateRenderer->render($this->templateRenderer->getIssueStub(), $record, $record->extra['github_issue_signature'], $exception), + comment: $this->templateRenderer->render($this->templateRenderer->getCommentStub(), $record, null, $exception), + ); + } + + public function formatBatch(array $records): array + { + return array_map([$this, 'format'], $records); + } + + private function hasErrorException(LogRecord $record): bool + { + return $record->level->value >= \Monolog\Level::Error->value + && isset($record->context['exception']) + && $record->context['exception'] instanceof Throwable; + } + + private function getException(LogRecord $record): ?Throwable + { + return $this->hasErrorException($record) ? $record->context['exception'] : null; + } +} diff --git a/src/Issues/Formatters/StackTraceFormatter.php b/src/Issues/Formatters/StackTraceFormatter.php new file mode 100644 index 0000000..935010d --- /dev/null +++ b/src/Issues/Formatters/StackTraceFormatter.php @@ -0,0 +1,70 @@ +filter(fn($line) => ! empty(trim($line))) + ->map(function ($line) { + if (trim($line) === '"}') { + return ''; + } + + if (str_contains($line, '{"exception":"[object] ')) { + return $this->formatInitialException($line); + } + + if (! Str::isMatch('/#[0-9]+ /', $line)) { + return $line; + } + + $line = str_replace(base_path(), '', $line); + + if (str_contains((string) $line, '/vendor/') && ! Str::isMatch("/BoundMethod\.php\([0-9]+\): App/", $line)) { + return self::VENDOR_FRAME_PLACEHOLDER; + } + + return $line; + }) + ->pipe($this->collapseVendorFrames(...)) + ->join("\n"); + } + + private function formatInitialException(string $line): array + { + [$message, $exception] = explode('{"exception":"[object] ', $line); + + return [ + $message, + $exception, + ]; + } + + private function collapseVendorFrames(Collection $lines): Collection + { + $hasVendorFrame = false; + + return $lines->filter(function ($line) use (&$hasVendorFrame) { + $isVendorFrame = str_contains($line, '[Vendor frames]'); + + if ($isVendorFrame) { + if ($hasVendorFrame) { + return false; + } + $hasVendorFrame = true; + } else { + $hasVendorFrame = false; + } + + return true; + }); + } +} diff --git a/src/Issues/Handler.php b/src/Issues/Handler.php index f6a6014..47ec192 100644 --- a/src/Issues/Handler.php +++ b/src/Issues/Handler.php @@ -8,6 +8,7 @@ use Monolog\Handler\AbstractProcessingHandler; use Monolog\Level; use Monolog\LogRecord; +use Naoray\LaravelGithubMonolog\Issues\Formatters\Formatted; class Handler extends AbstractProcessingHandler { @@ -43,7 +44,7 @@ public function __construct( protected function write(LogRecord $record): void { if (! $record->formatted instanceof Formatted) { - throw new \RuntimeException('Record must be formatted with '.Formatted::class); + throw new \RuntimeException('Record must be formatted with ' . Formatted::class); } $formatted = $record->formatted; @@ -78,7 +79,7 @@ private function findExistingIssue(LogRecord $record): ?array return $this->client ->get('/search/issues', [ - 'q' => "repo:{$this->repo} is:issue is:open label:".self::DEFAULT_LABEL." \"Signature: {$record->extra['github_issue_signature']}\"", + 'q' => "repo:{$this->repo} is:issue is:open label:" . self::DEFAULT_LABEL . " \"Signature: {$record->extra['github_issue_signature']}\"", ]) ->throw() ->json('items.0', null); @@ -117,7 +118,7 @@ private function createFallbackIssue(Formatted $formatted, string $errorMessage) { $this->client ->post("/repos/{$this->repo}/issues", [ - 'title' => '[GitHub Monolog Error] '.$formatted->title, + 'title' => '[GitHub Monolog Error] ' . $formatted->title, 'body' => "**Original Error Message:**\n{$formatted->body}\n\n**Integration Error:**\n{$errorMessage}", 'labels' => array_merge($this->labels, ['monolog-integration-error']), ]) diff --git a/src/Issues/StubLoader.php b/src/Issues/StubLoader.php new file mode 100644 index 0000000..ce8b4b9 --- /dev/null +++ b/src/Issues/StubLoader.php @@ -0,0 +1,25 @@ +issueStub = $this->stubLoader->load('issue'); + $this->commentStub = $this->stubLoader->load('comment'); + $this->previousExceptionStub = $this->stubLoader->load('previous_exception'); + } + + public function render(string $template, LogRecord $record, ?string $signature = null, ?Throwable $exception = null): string + { + $replacements = $this->buildReplacements($record, $signature, $exception); + + return Str::of($template) + ->replace(array_keys($replacements), array_values($replacements)) + ->toString(); + } + + public function renderTitle(LogRecord $record, ?Throwable $exception = null): string + { + if (!$exception) { + return Str::of('[{level}] {message}') + ->replace('{level}', $record->level->getName()) + ->replace('{message}', Str::limit($record->message, self::TITLE_MAX_LENGTH)) + ->toString(); + } + + return $this->exceptionFormatter->formatTitle($exception, $record->level->getName()); + } + + public function getIssueStub(): string + { + return $this->issueStub; + } + + public function getCommentStub(): string + { + return $this->commentStub; + } + + private function buildReplacements(LogRecord $record, ?string $signature, ?Throwable $exception): array + { + $exceptionDetails = $exception ? $this->exceptionFormatter->format($record) : []; + + return array_filter([ + '{level}' => $record->level->getName(), + '{message}' => $record->message, + '{simplified_stack_trace}' => $exceptionDetails['simplified_stack_trace'] ?? '', + '{full_stack_trace}' => $exceptionDetails['full_stack_trace'] ?? '', + '{previous_exceptions}' => $exception ? $this->formatPrevious($exception) : '', + '{context}' => $this->formatContext($record->context), + '{extra}' => $this->formatExtra($record->extra), + '{signature}' => $signature, + ]); + } + + private function formatPrevious(Throwable $exception): string + { + $previous = $exception->getPrevious(); + if (!$previous) { + return ''; + } + + $exceptions = collect() + ->range(1, self::MAX_PREVIOUS_EXCEPTIONS) + ->map(function ($count) use (&$previous) { + if (!$previous) { + return null; + } + + $current = $previous; + $previous = $previous->getPrevious(); + + $details = $this->exceptionFormatter->format(new LogRecord( + datetime: new \DateTimeImmutable(), + channel: 'github', + level: \Monolog\Level::Error, + message: '', + context: ['exception' => $current], + extra: [] + )); + + return Str::of($this->previousExceptionStub) + ->replace( + ['{count}', '{type}', '{simplified_stack_trace}', '{full_stack_trace}'], + [$count, get_class($current), $details['simplified_stack_trace'], str_replace(base_path(), '', $details['full_stack_trace'])] + ) + ->toString(); + }) + ->filter() + ->join("\n\n"); + + if (empty($exceptions)) { + return ''; + } + + if ($previous) { + $exceptions .= "\n\n> Note: Additional previous exceptions were truncated\n"; + } + + return $exceptions; + } + + private function formatContext(array $context): string + { + if (empty($context)) { + return ''; + } + + return sprintf( + "**Context:**\n```json\n%s\n```\n", + json_encode(Arr::except($context, ['exception']), JSON_PRETTY_PRINT) + ); + } + + private function formatExtra(array $extra): string + { + if (empty($extra)) { + return ''; + } + + return sprintf( + "**Extra Data:**\n```json\n%s\n```", + json_encode($extra, JSON_PRETTY_PRINT) + ); + } +} diff --git a/stubs/comment.md b/stubs/comment.md new file mode 100644 index 0000000..36986d3 --- /dev/null +++ b/stubs/comment.md @@ -0,0 +1,28 @@ +# New Occurrence + +**Log Level:** {level} + +{message} + +**Simplified Stack Trace:** +```php +{simplified_stack_trace} +``` + +
+Complete Stack Trace + +```php +{full_stack_trace} +``` +
+ +
+Previous Exceptions + +{previous_exceptions} +
+ +{context} + +{extra} diff --git a/stubs/issue.md b/stubs/issue.md new file mode 100644 index 0000000..fcd6903 --- /dev/null +++ b/stubs/issue.md @@ -0,0 +1,28 @@ +**Log Level:** {level} + +{message} + +**Simplified Stack Trace:** +```php +{simplified_stack_trace} +``` + +
+Complete Stack Trace + +```php +{full_stack_trace} +``` +
+ +
+Previous Exceptions + +{previous_exceptions} +
+ +{context} + +{extra} + + diff --git a/stubs/previous_exception.md b/stubs/previous_exception.md new file mode 100644 index 0000000..cebdf93 --- /dev/null +++ b/stubs/previous_exception.md @@ -0,0 +1,15 @@ +### Previous Exception #{count} +**Type:** {type} + +**Simplified Stack Trace:** +```php +{simplified_stack_trace} +``` + +
+Complete Stack Trace + +```php +{full_stack_trace} +``` +
diff --git a/tests/GithubIssueHandlerFactoryTest.php b/tests/GithubIssueHandlerFactoryTest.php index d9d158b..c8f6dde 100644 --- a/tests/GithubIssueHandlerFactoryTest.php +++ b/tests/GithubIssueHandlerFactoryTest.php @@ -5,7 +5,7 @@ use Naoray\LaravelGithubMonolog\Deduplication\DeduplicationHandler; use Naoray\LaravelGithubMonolog\Deduplication\DefaultSignatureGenerator; use Naoray\LaravelGithubMonolog\GithubIssueHandlerFactory; -use Naoray\LaravelGithubMonolog\Issues\Formatter; +use Naoray\LaravelGithubMonolog\Issues\Formatters\IssueFormatter; use Naoray\LaravelGithubMonolog\Issues\Handler; function getWrappedHandler(DeduplicationHandler $handler): Handler @@ -37,8 +37,7 @@ function getSignatureGenerator(DeduplicationHandler $handler): mixed 'labels' => ['test-label'], ]; - $this->signatureGenerator = new DefaultSignatureGenerator; - $this->factory = new GithubIssueHandlerFactory($this->signatureGenerator); + $this->factory = app()->make(GithubIssueHandlerFactory::class); }); test('it creates a logger with deduplication handler', function () { @@ -62,13 +61,13 @@ function getSignatureGenerator(DeduplicationHandler $handler): mixed ->and($wrappedHandler->getLevel()) ->toBe(Level::Error) ->and($wrappedHandler->getFormatter()) - ->toBeInstanceOf(Formatter::class); + ->toBeInstanceOf(IssueFormatter::class); }); test('it throws exception when required config is missing', function () { - expect(fn () => ($this->factory)([]))->toThrow(\InvalidArgumentException::class); - expect(fn () => ($this->factory)(['repo' => 'test/repo']))->toThrow(\InvalidArgumentException::class); - expect(fn () => ($this->factory)(['token' => 'test-token']))->toThrow(\InvalidArgumentException::class); + expect(fn() => ($this->factory)([]))->toThrow(\InvalidArgumentException::class); + expect(fn() => ($this->factory)(['repo' => 'test/repo']))->toThrow(\InvalidArgumentException::class); + expect(fn() => ($this->factory)(['token' => 'test-token']))->toThrow(\InvalidArgumentException::class); }); test('it configures buffer settings correctly', function () { @@ -126,8 +125,7 @@ function getSignatureGenerator(DeduplicationHandler $handler): mixed }); test('it uses same signature generator across components', function () { - $factory = new GithubIssueHandlerFactory(new DefaultSignatureGenerator); - $logger = $factory([ + $logger = ($this->factory)([ 'repo' => 'test/repo', 'token' => 'test-token', ]); @@ -145,14 +143,14 @@ function getSignatureGenerator(DeduplicationHandler $handler): mixed }); test('it throws exception for invalid deduplication time', function () { - expect(fn () => ($this->factory)([ + expect(fn() => ($this->factory)([ ...$this->config, 'deduplication' => [ 'time' => -1, ], ]))->toThrow(\InvalidArgumentException::class, 'Deduplication time must be a positive integer'); - expect(fn () => ($this->factory)([ + expect(fn() => ($this->factory)([ ...$this->config, 'deduplication' => [ 'time' => 'invalid', diff --git a/tests/Issues/Formatters/ExceptionFormatterTest.php b/tests/Issues/Formatters/ExceptionFormatterTest.php new file mode 100644 index 0000000..01c5581 --- /dev/null +++ b/tests/Issues/Formatters/ExceptionFormatterTest.php @@ -0,0 +1,79 @@ +stackTraceFormatter = Mockery::mock(StackTraceFormatter::class); + $this->formatter = new ExceptionFormatter( + stackTraceFormatter: $this->stackTraceFormatter, + ); +}); + +test('it formats exception details', function () { + $exception = new RuntimeException('Test exception'); + $record = new LogRecord( + datetime: new DateTimeImmutable(), + channel: 'test', + level: Level::Error, + message: 'Test message', + context: ['exception' => $exception], + extra: [], + ); + + $this->stackTraceFormatter->shouldReceive('format') + ->once() + ->with($exception->getTraceAsString()) + ->andReturn('formatted stack trace'); + + $result = $this->formatter->format($record); + + expect($result) + ->toBeArray() + ->toHaveKeys(['message', 'simplified_stack_trace', 'full_stack_trace']) + ->and($result['message'])->toBe('Test exception') + ->and($result['simplified_stack_trace'])->toContain('formatted stack trace') + ->and($result['full_stack_trace'])->toContain($exception->getTraceAsString()); +}); + +test('it returns empty array for non-exception records', function () { + $record = new LogRecord( + datetime: new DateTimeImmutable(), + channel: 'test', + level: Level::Error, + message: 'Test message', + context: [], + extra: [], + ); + + expect($this->formatter->format($record))->toBeArray()->toBeEmpty(); +}); + +test('it formats exception title', function () { + $exception = new RuntimeException('Test exception'); + + $title = $this->formatter->formatTitle($exception, 'ERROR'); + + expect($title) + ->toContain('[ERROR]') + ->toContain('RuntimeException') + ->toContain('Test exception'); +}); + +test('it truncates long exception messages in title', function () { + $longMessage = str_repeat('a', 150); + $exception = new RuntimeException($longMessage); + + $title = $this->formatter->formatTitle($exception, 'ERROR'); + + // Title format: [ERROR] RuntimeException in /path/to/file.php:123 - {truncated_message} + // We check that the message part is truncated + expect($title) + ->toContain('[ERROR]') + ->toContain('RuntimeException') + ->toContain('...'); +}); diff --git a/tests/Issues/FormatterTest.php b/tests/Issues/Formatters/IssueFormatterTest.php similarity index 89% rename from tests/Issues/FormatterTest.php rename to tests/Issues/Formatters/IssueFormatterTest.php index d2ad9e1..0ebadfd 100644 --- a/tests/Issues/FormatterTest.php +++ b/tests/Issues/Formatters/IssueFormatterTest.php @@ -2,11 +2,14 @@ use Monolog\Level; use Monolog\LogRecord; -use Naoray\LaravelGithubMonolog\Issues\Formatted; -use Naoray\LaravelGithubMonolog\Issues\Formatter; +use Naoray\LaravelGithubMonolog\Issues\Formatters\Formatted; +use Naoray\LaravelGithubMonolog\Issues\Formatters\IssueFormatter; + +beforeEach(function () { + $this->formatter = app()->make(IssueFormatter::class); +}); test('it formats basic log records', function () { - $formatter = new Formatter; $record = new LogRecord( datetime: new DateTimeImmutable, channel: 'test', @@ -16,7 +19,7 @@ extra: ['github_issue_signature' => 'test-signature'] ); - $formatted = $formatter->format($record); + $formatted = $this->formatter->format($record); expect($formatted) ->toBeInstanceOf(Formatted::class) @@ -26,7 +29,6 @@ }); test('it formats exceptions with file and line information', function () { - $formatter = new Formatter; $exception = new RuntimeException('Test exception'); $record = new LogRecord( datetime: new DateTimeImmutable, @@ -37,7 +39,7 @@ extra: ['github_issue_signature' => 'test-signature'] ); - $formatted = $formatter->format($record); + $formatted = $this->formatter->format($record); expect($formatted->title) ->toContain('RuntimeException') @@ -48,7 +50,6 @@ }); test('it truncates long titles', function () { - $formatter = new Formatter; $longMessage = str_repeat('a', 90); $record = new LogRecord( datetime: new DateTimeImmutable, @@ -59,13 +60,12 @@ extra: ['github_issue_signature' => 'test-signature'] ); - $formatted = $formatter->format($record); + $formatted = $this->formatter->format($record); expect(mb_strlen($formatted->title))->toBeLessThanOrEqual(100); }); test('it includes context data in formatted output', function () { - $formatter = new Formatter; $record = new LogRecord( datetime: new DateTimeImmutable, channel: 'test', @@ -75,7 +75,7 @@ extra: ['github_issue_signature' => 'test-signature'] ); - $formatted = $formatter->format($record); + $formatted = $this->formatter->format($record); expect($formatted->body) ->toContain('"user_id": 123') @@ -83,8 +83,6 @@ }); test('it formats stack traces with collapsible vendor frames', function () { - $formatter = new Formatter; - $exception = new Exception('Test exception'); $reflection = new ReflectionClass($exception); $traceProperty = $reflection->getProperty('trace'); @@ -127,7 +125,7 @@ extra: ['github_issue_signature' => 'test-signature'] ); - $formatted = $formatter->format($record); + $formatted = $this->formatter->format($record); // Verify that app frames are directly visible expect($formatted->body) diff --git a/tests/Issues/Formatters/StackTraceFormatterTest.php b/tests/Issues/Formatters/StackTraceFormatterTest.php new file mode 100644 index 0000000..6711a47 --- /dev/null +++ b/tests/Issues/Formatters/StackTraceFormatterTest.php @@ -0,0 +1,56 @@ +formatter = new StackTraceFormatter(); +}); + +test('it formats stack trace', function () { + $stackTrace = <<<'TRACE' +#0 /app/Http/Controllers/TestController.php(25): TestController->testMethod() +#1 /vendor/laravel/framework/src/Testing.php(50): VendorClass->vendorMethod() +#2 /vendor/another/package/src/File.php(100): AnotherVendorClass->anotherVendorMethod() +#3 /app/Services/TestService.php(30): TestService->serviceMethod() +TRACE; + + $formatted = $this->formatter->format($stackTrace); + + expect($formatted) + ->toContain('/app/Http/Controllers/TestController.php') + ->toContain('/app/Services/TestService.php') + ->toContain('[Vendor frames]') + ->not->toContain('/vendor/laravel/framework/src/Testing.php') + ->not->toContain('/vendor/another/package/src/File.php'); +}); + +test('it collapses consecutive vendor frames', function () { + $stackTrace = <<<'TRACE' +#0 /vendor/package1/src/File1.php(10): Method1() +#1 /vendor/package1/src/File2.php(20): Method2() +#2 /vendor/package2/src/File3.php(30): Method3() +TRACE; + + $formatted = $this->formatter->format($stackTrace); + + expect($formatted) + ->toContain('[Vendor frames]') + ->not->toContain('/vendor/package1/src/File1.php') + ->not->toContain('/vendor/package2/src/File3.php') + // Should only appear once even though there are multiple vendor frames + ->and(substr_count($formatted, '[Vendor frames]'))->toBe(1); +}); + +test('it preserves non-vendor frames', function () { + $stackTrace = <<<'TRACE' +#0 /app/Http/Controllers/TestController.php(25): TestController->testMethod() +#1 /app/Services/TestService.php(30): TestService->serviceMethod() +TRACE; + + $formatted = $this->formatter->format($stackTrace); + + expect($formatted) + ->toContain('/app/Http/Controllers/TestController.php') + ->toContain('/app/Services/TestService.php') + ->not->toContain('[Vendor frames]'); +}); diff --git a/tests/Issues/HandlerTest.php b/tests/Issues/HandlerTest.php index 8e886f4..4f92749 100644 --- a/tests/Issues/HandlerTest.php +++ b/tests/Issues/HandlerTest.php @@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Http; use Monolog\Level; use Monolog\LogRecord; -use Naoray\LaravelGithubMonolog\Issues\Formatter; +use Naoray\LaravelGithubMonolog\Issues\Formatters\IssueFormatter; use Naoray\LaravelGithubMonolog\Issues\Handler; function createHandler(): Handler @@ -20,7 +20,7 @@ function createHandler(): Handler bubble: true ); - $handler->setFormatter(new Formatter); + $handler->setFormatter(app()->make(IssueFormatter::class)); return $handler; } diff --git a/tests/Issues/StubLoaderTest.php b/tests/Issues/StubLoaderTest.php new file mode 100644 index 0000000..029135a --- /dev/null +++ b/tests/Issues/StubLoaderTest.php @@ -0,0 +1,85 @@ +loader = new StubLoader(); + File::partialMock(); +}); + +test('it loads stub from published path if it exists', function () { + $publishedPath = resource_path('views/vendor/github-monolog/issue.md'); + File::shouldReceive('exists') + ->with($publishedPath) + ->andReturn(true); + File::shouldReceive('get') + ->with($publishedPath) + ->andReturn('published content'); + + expect($this->loader->load('issue'))->toBe('published content'); +}); + +test('it falls back to package stub if published stub does not exist', function () { + $publishedPath = resource_path('views/vendor/github-monolog/issue.md'); + $packagePath = __DIR__ . '/../../resources/views/issue.md'; + $expectedContent = <<<'MD' +**Log Level:** {level} + +{message} + +**Simplified Stack Trace:** +```php +{simplified_stack_trace} +``` + +
+Complete Stack Trace + +```php +{full_stack_trace} +``` +
+ +
+Previous Exceptions + +{previous_exceptions} +
+ +{context} + +{extra} + + + +MD; + + File::shouldReceive('exists') + ->with($publishedPath) + ->andReturn(false); + File::shouldReceive('exists') + ->with($packagePath) + ->andReturn(true); + File::shouldReceive('get') + ->with($packagePath) + ->andReturn($expectedContent); + + expect($this->loader->load('issue'))->toBe($expectedContent); +}); + +test('it throws exception if stub does not exist', function () { + $publishedPath = resource_path('views/vendor/github-monolog/nonexistent.md'); + $packagePath = __DIR__ . '/../../resources/views/nonexistent.md'; + + File::shouldReceive('exists') + ->with($publishedPath) + ->andReturn(false); + File::shouldReceive('exists') + ->with($packagePath) + ->andReturn(false); + + expect(fn() => $this->loader->load('nonexistent')) + ->toThrow(FileNotFoundException::class); +}); diff --git a/tests/Issues/TemplateRendererTest.php b/tests/Issues/TemplateRendererTest.php new file mode 100644 index 0000000..f66799c --- /dev/null +++ b/tests/Issues/TemplateRendererTest.php @@ -0,0 +1,142 @@ +stubLoader = Mockery::mock(StubLoader::class); + /** @var ExceptionFormatter&MockInterface */ + $this->exceptionFormatter = Mockery::mock(ExceptionFormatter::class); + + $this->stubLoader->shouldReceive('load') + ->with('issue') + ->andReturn('**Log Level:** {level}\n{message}\n{previous_exceptions}\n{context}\n{extra}\n{signature}'); + $this->stubLoader->shouldReceive('load') + ->with('comment') + ->andReturn('# New Occurrence\n**Log Level:** {level}\n{message}'); + $this->stubLoader->shouldReceive('load') + ->with('previous_exception') + ->andReturn('## Previous Exception #{count}\n{type}\n{simplified_stack_trace}'); + + $this->renderer = new TemplateRenderer( + exceptionFormatter: $this->exceptionFormatter, + stubLoader: $this->stubLoader, + ); +}); + +test('it renders basic log record', function () { + $record = new LogRecord( + datetime: new DateTimeImmutable(), + channel: 'test', + level: Level::Error, + message: 'Test message', + context: [], + extra: [], + ); + + $rendered = $this->renderer->render($this->stubLoader->load('issue'), $record); + + expect($rendered) + ->toContain('**Log Level:** ERROR') + ->toContain('Test message'); +}); + +test('it renders title without exception', function () { + $record = new LogRecord( + datetime: new DateTimeImmutable(), + channel: 'test', + level: Level::Error, + message: 'Test message', + context: [], + extra: [], + ); + + $title = $this->renderer->renderTitle($record); + + expect($title)->toBe('[ERROR] Test message'); +}); + +test('it renders title with exception', function () { + $record = new LogRecord( + datetime: new DateTimeImmutable(), + channel: 'test', + level: Level::Error, + message: 'Test message', + context: ['exception' => new RuntimeException('Test exception')], + extra: [], + ); + + $this->exceptionFormatter->shouldReceive('formatTitle') + ->once() + ->andReturn('[ERROR] RuntimeException: Test exception'); + + $title = $this->renderer->renderTitle($record, $record->context['exception']); + + expect($title)->toBe('[ERROR] RuntimeException: Test exception'); +}); + +test('it renders context data', function () { + $record = new LogRecord( + datetime: new DateTimeImmutable(), + channel: 'test', + level: Level::Error, + message: 'Test message', + context: ['user_id' => 123], + extra: [], + ); + + $rendered = $this->renderer->render($this->stubLoader->load('issue'), $record); + + expect($rendered) + ->toContain('**Context:**') + ->toContain('"user_id": 123'); +}); + +test('it renders extra data', function () { + $record = new LogRecord( + datetime: new DateTimeImmutable(), + channel: 'test', + level: Level::Error, + message: 'Test message', + context: [], + extra: ['request_id' => 'abc123'], + ); + + $rendered = $this->renderer->render($this->stubLoader->load('issue'), $record); + + expect($rendered) + ->toContain('**Extra Data:**') + ->toContain('"request_id": "abc123"'); +}); + +test('it renders previous exceptions', function () { + $previous = new RuntimeException('Previous exception'); + $exception = new RuntimeException('Test exception', previous: $previous); + $record = new LogRecord( + datetime: new DateTimeImmutable(), + channel: 'test', + level: Level::Error, + message: 'Test message', + context: ['exception' => $exception], + extra: [], + ); + + $this->exceptionFormatter->shouldReceive('format') + ->twice() + ->andReturn([ + 'simplified_stack_trace' => 'simplified stack trace', + 'full_stack_trace' => 'full stack trace', + ]); + + $rendered = $this->renderer->render($this->stubLoader->load('issue'), $record, null, $exception); + + expect($rendered) + ->toContain('Previous Exception #1') + ->toContain(RuntimeException::class) + ->toContain('simplified stack trace'); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 9fdb6e2..0071a29 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,10 +2,18 @@ namespace Naoray\LaravelGithubMonolog\Tests; +use Naoray\LaravelGithubMonolog\GithubMonologServiceProvider; use Orchestra\Testbench\TestCase as Orchestra; class TestCase extends Orchestra { + protected function getPackageProviders($app): array + { + return [ + GithubMonologServiceProvider::class, + ]; + } + protected function defineEnvironment($app): void { // Configure the default cache store to array for testing