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