diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index d21c0c3..e7d9a52 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -31,7 +31,7 @@ jobs: os: [ubuntu-latest] php: [8.4, 8.3] laravel: [11.*] - stability: [prefer-lowest, prefer-stable] + stability: [prefer-stable] include: - laravel: 11.* testbench: 9.* diff --git a/README.md b/README.md index fdeadd7..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,51 +104,46 @@ 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 -Group similar errors to avoid duplicate issues. By default, the package uses file-based storage. Customize the storage and time window to fit your application. +Group similar errors to avoid duplicate issues. The package uses Laravel's cache system for deduplication storage. ```php 'github' => [ // ... basic config from above ... 'deduplication' => [ - 'store' => 'file', // Default store - 'time' => 60, // Time window in seconds + 'time' => 60, // Time window in seconds - how long to wait before creating a new issue + 'store' => null, // Uses your default cache store (from cache.default) + 'prefix' => 'dedup', // Prefix for cache keys ], ] ``` -#### Alternative Storage Options - -Consider other storage options in these Laravel-specific scenarios: - -- **Redis Store**: Use when: - - Running async queue jobs (file storage won't work across processes) - - Using Laravel Horizon for queue management - - Running multiple application instances behind a load balancer - - ```php - 'deduplication' => [ - 'store' => 'redis', - 'prefix' => 'github-monolog:', - 'connection' => 'default', // Uses your Laravel Redis connection - ], - ``` - -- **Database Store**: Use when: - - Running queue jobs but Redis isn't available - - Need to persist deduplication data across deployments - - Want to query/debug deduplication history via database - - ```php - 'deduplication' => [ - 'store' => 'database', - 'table' => 'github_monolog_deduplication', - 'connection' => null, // Uses your default database connection - ], - ``` +For cache store configuration, refer to the [Laravel Cache documentation](https://laravel.com/docs/cache). ### Buffering diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..2dfd682 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,89 @@ +# Upgrade Guide + +## Upgrading from 2.x to 3.0 + +### Breaking Changes + +Version 3.0 introduces several breaking changes in how deduplication storage is handled: + +1. **Removed Custom Store Implementations** + - FileStore, RedisStore, and DatabaseStore have been removed + - All deduplication storage now uses Laravel's cache system + +2. **Configuration Changes** + - Store-specific configuration options have been removed + - New simplified cache-based configuration + +### Migration Steps + +1. **Update Package** + ```bash + composer require naoray/laravel-github-monolog:^3.0 + ``` + +2. **Run Cleanup** + - Keep your old configuration in place + - Run the cleanup code in [Cleanup Code](#cleanup-code) to remove old storage artifacts + - The cleanup code needs your old configuration to know what to clean up + +3. **Update Configuration** + - Migrate to new store-specific configuration + - Add new cache-based configuration + - Configure Laravel cache as needed + +### Configuration Updates + +#### Before (2.x) +```php +'deduplication' => [ + 'store' => 'redis', // or 'file', 'database' + 'connection' => 'default', // Redis/Database connection + 'prefix' => 'github-monolog:', // Redis prefix + 'table' => 'github_monolog_deduplication', // Database table + 'time' => 60, +], +``` + +#### After (3.0) +```php +'deduplication' => [ + 'store' => null, // (optional) Uses Laravel's default cache store + 'time' => 60, // Time window in seconds + 'prefix' => 'dedup', // Cache key prefix +], +``` + +### Cleanup Code + +Before updating your configuration to the new format, you should clean up artifacts from the 2.x version. The cleanup code uses your existing configuration to find and remove old storage: + +```php +use Illuminate\Support\Facades\{Schema, Redis, File, DB}; + +// Get your current config +$config = config('logging.channels.github.deduplication', []); +$store = $config['store'] ?? 'file'; + +if ($store === 'database') { + // Clean up database table using your configured connection and table name + $connection = $config['connection'] ?? config('database.default'); + $table = $config['table'] ?? 'github_monolog_deduplication'; + + Schema::connection($connection)->dropIfExists($table); +} + +if ($store === 'redis') { + // Clean up Redis entries using your configured connection and prefix + $connection = $config['connection'] ?? 'default'; + $prefix = $config['prefix'] ?? 'github-monolog:'; + Redis::connection($connection)->del($prefix . 'dedup'); +} + +if ($store === 'file') { + // Clean up file storage using your configured path + $path = $config['path'] ?? storage_path('logs/github-monolog-deduplication.log'); + if (File::exists($path)) { + File::delete($path); + } +} +``` diff --git a/composer.json b/composer.json index 4f1d0b2..5c89ef9 100644 --- a/composer.json +++ b/composer.json @@ -23,16 +23,17 @@ "illuminate/http": "^11.0", "illuminate/support": "^11.0", "illuminate/filesystem": "^11.0", + "illuminate/cache": "^11.0", "monolog/monolog": "^3.6" }, "require-dev": { "laravel/pint": "^1.14", - "nunomaduro/collision": "^8.1.1||^7.10.0", + "nunomaduro/collision": "^8.1.1", "larastan/larastan": "^2.9", - "orchestra/testbench": "^9.0.0||^8.22.0", - "pestphp/pest": "^2.35|^3.0", - "pestphp/pest-plugin-arch": "^2.7||^3.0", - "pestphp/pest-plugin-laravel": "^2.4||^3.0", + "orchestra/testbench": "^9.0.0", + "pestphp/pest": "^3.0", + "pestphp/pest-plugin-arch": "^3.0", + "pestphp/pest-plugin-laravel": "^3.0", "phpstan/extension-installer": "^1.3", "phpstan/phpstan-deprecation-rules": "^1.1", "phpstan/phpstan-phpunit": "^1.3" @@ -62,6 +63,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/Deduplication/CacheManager.php b/src/Deduplication/CacheManager.php new file mode 100644 index 0000000..e4d04b8 --- /dev/null +++ b/src/Deduplication/CacheManager.php @@ -0,0 +1,68 @@ +store = $store ?? config('cache.default'); + $this->cache = Cache::store($this->store); + } + + public function has(string $signature): bool + { + return $this->cache->has($this->composeKey($signature)); + } + + public function add(string $signature): void + { + $this->cache->put( + $this->composeKey($signature), + Carbon::now()->timestamp, + $this->ttl + ); + } + + /** + * Clear all entries for the current prefix. + * Note: This is a best-effort operation and might not work with all cache stores. + */ + public function clear(): void + { + // For Redis/Memcached stores that support tag-like operations + if (method_exists($this->cache->getStore(), 'flush')) { + $this->cache->getStore()->flush(); + + return; + } + + // For other stores, we'll have to rely on TTL cleanup + // You might want to implement a more specific cleanup strategy + // based on your cache store if needed + } + + private function composeKey(string $signature): string + { + return implode(self::KEY_SEPARATOR, [ + self::KEY_PREFIX, + $this->prefix, + $signature, + ]); + } +} diff --git a/src/Deduplication/DeduplicationHandler.php b/src/Deduplication/DeduplicationHandler.php index 8e4aa94..5e5305c 100644 --- a/src/Deduplication/DeduplicationHandler.php +++ b/src/Deduplication/DeduplicationHandler.php @@ -7,14 +7,17 @@ use Monolog\Handler\HandlerInterface; use Monolog\Level; use Monolog\LogRecord; -use Naoray\LaravelGithubMonolog\Deduplication\Stores\StoreInterface; class DeduplicationHandler extends BufferHandler { + private CacheManager $cache; + public function __construct( HandlerInterface $handler, - protected StoreInterface $store, protected SignatureGeneratorInterface $signatureGenerator, + string $store = 'default', + string $prefix = 'github-monolog:dedup:', + int $ttl = 60, int|string|Level $level = Level::Error, int $bufferLimit = 0, bool $bubble = true, @@ -27,6 +30,8 @@ public function __construct( bubble: $bubble, flushOnOverflow: $flushOnOverflow, ); + + $this->cache = new CacheManager($store, $prefix, $ttl); } public function flush(): void @@ -42,12 +47,12 @@ public function flush(): void // Create new record with signature in extra data $record = $record->with(extra: ['github_issue_signature' => $signature] + $record->extra); - // If the record is a duplicate, we don't want to add it to the store - if ($this->store->isDuplicate($record, $signature)) { + // If the record is a duplicate, we don't want to process it + if ($this->cache->has($signature)) { return null; } - $this->store->add($record, $signature); + $this->cache->add($signature); return $record; }) diff --git a/src/Deduplication/Stores/AbstractStore.php b/src/Deduplication/Stores/AbstractStore.php deleted file mode 100644 index c8805bc..0000000 --- a/src/Deduplication/Stores/AbstractStore.php +++ /dev/null @@ -1,53 +0,0 @@ -get() as $entry) { - [$timestamp, $storedSignature] = explode(':', $entry, 2); - $timestamp = (int) $timestamp; - - if ($this->isExpired($timestamp)) { - continue; - } - - if ($storedSignature === $signature) { - $foundDuplicate = true; - } - } - - return $foundDuplicate; - } - - protected function isExpired(int $timestamp): bool - { - return $this->getTimestampValidity() > $timestamp; - } - - protected function getTimestampValidity(): int - { - return $this->getTimestamp() - $this->time; - } - - protected function getTimestamp(): int - { - return Carbon::now()->timestamp; - } -} diff --git a/src/Deduplication/Stores/DatabaseStore.php b/src/Deduplication/Stores/DatabaseStore.php deleted file mode 100644 index a84882c..0000000 --- a/src/Deduplication/Stores/DatabaseStore.php +++ /dev/null @@ -1,69 +0,0 @@ -connection = $connection === 'default' ? config('database.default') : $connection; - $this->table = $table; - - $this->ensureTableExists(); - } - - public function get(): array - { - return DB::connection($this->connection) - ->table($this->table) - ->where('created_at', '>=', $this->getTimestampValidity()) - ->get() - ->map(fn ($row) => $this->buildEntry($row->signature, $row->created_at)) - ->all(); - } - - public function add(LogRecord $record, string $signature): void - { - DB::connection($this->connection) - ->table($this->table) - ->insert([ - 'signature' => $signature, - 'created_at' => $this->getTimestamp(), - ]); - } - - public function cleanup(): void - { - DB::connection($this->connection) - ->table($this->table) - ->where('created_at', '<', $this->getTimestampValidity()) - ->delete(); - } - - public function ensureTableExists(): void - { - if (! Schema::connection($this->connection)->hasTable($this->table)) { - Schema::connection($this->connection)->create($this->table, function (Blueprint $table) { - $table->id(); - $table->string('signature'); - $table->integer('created_at')->index(); - - $table->index(['signature', 'created_at']); - }); - } - } -} diff --git a/src/Deduplication/Stores/FileStore.php b/src/Deduplication/Stores/FileStore.php deleted file mode 100644 index aea6587..0000000 --- a/src/Deduplication/Stores/FileStore.php +++ /dev/null @@ -1,61 +0,0 @@ -path)); - } - - public function get(): array - { - if ($this->fileIsMissing()) { - return []; - } - - return Str::of(File::get($this->path)) - ->explode(PHP_EOL) - ->filter(fn ($entry) => $entry && str_contains($entry, ':') && is_numeric(explode(':', $entry, 2)[0])) - ->toArray(); - } - - public function add(LogRecord $record, string $signature): void - { - $entry = $this->buildEntry($signature, $this->getTimestamp()); - $content = File::exists($this->path) ? File::get($this->path) : ''; - - File::put( - $this->path, - ($content ? $content.PHP_EOL : '').$entry - ); - } - - public function cleanup(): void - { - $valid = collect($this->get()) - ->filter(function ($entry) { - [$timestamp] = explode(':', $entry, 2); - - return is_numeric($timestamp) && ! $this->isExpired((int) $timestamp); - }) - ->join(PHP_EOL); - - // overwrite the file with the new content - File::put($this->path, $valid); - } - - protected function fileIsMissing(): bool - { - return File::missing($this->path); - } -} diff --git a/src/Deduplication/Stores/RedisStore.php b/src/Deduplication/Stores/RedisStore.php deleted file mode 100644 index 4ef67e1..0000000 --- a/src/Deduplication/Stores/RedisStore.php +++ /dev/null @@ -1,63 +0,0 @@ -connection = $connection; - $this->prefix = $prefix; - } - - private function redis() - { - return Redis::connection($this->connection === 'default' ? null : $this->connection); - } - - // Key Management - public function getKey(): string - { - return $this->prefix.'dedup'; - } - - // Storage Operations - public function add(LogRecord $record, string $signature): void - { - $this->redis()->zadd($this->getKey(), [ - $signature => $this->getTimestamp(), - ]); - } - - public function get(): array - { - $entries = $this->redis()->zrangebyscore( - $this->getKey(), - $this->getTimestampValidity(), - '+inf', - ['withscores' => true] - ); - - return array_map( - fn ($entry, $score) => $this->buildEntry($entry, (int) $score), - array_keys($entries), - array_values($entries) - ); - } - - public function cleanup(): void - { - $this->redis()->zremrangebyscore($this->getKey(), '-inf', $this->getTimestampValidity()); - } -} diff --git a/src/Deduplication/Stores/StoreInterface.php b/src/Deduplication/Stores/StoreInterface.php deleted file mode 100644 index cba2edb..0000000 --- a/src/Deduplication/Stores/StoreInterface.php +++ /dev/null @@ -1,30 +0,0 @@ - - */ - public function get(): array; - - /** - * Add a new deduplication entry - */ - public function add(LogRecord $record, string $signature): void; - - /** - * Check if a record with the given signature is a duplicate - */ - public function isDuplicate(LogRecord $record, string $signature): bool; - - /** - * Clean up expired entries - */ - public function cleanup(): void; -} diff --git a/src/GithubIssueHandlerFactory.php b/src/GithubIssueHandlerFactory.php index 6931093..2ac29f8 100644 --- a/src/GithubIssueHandlerFactory.php +++ b/src/GithubIssueHandlerFactory.php @@ -9,15 +9,15 @@ use Naoray\LaravelGithubMonolog\Deduplication\DeduplicationHandler; use Naoray\LaravelGithubMonolog\Deduplication\DefaultSignatureGenerator; use Naoray\LaravelGithubMonolog\Deduplication\SignatureGeneratorInterface; -use Naoray\LaravelGithubMonolog\Deduplication\Stores\DatabaseStore; -use Naoray\LaravelGithubMonolog\Deduplication\Stores\FileStore; -use Naoray\LaravelGithubMonolog\Deduplication\Stores\RedisStore; -use Naoray\LaravelGithubMonolog\Deduplication\Stores\StoreInterface; -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); @@ -49,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; } @@ -67,34 +67,20 @@ protected function wrapWithDeduplication(Handler $handler, array $config): Dedup /** @var SignatureGeneratorInterface $signatureGenerator */ $signatureGenerator = new $signatureGeneratorClass; + $deduplication = Arr::get($config, 'deduplication', []); + return new DeduplicationHandler( handler: $handler, - store: $this->createStore($config), signatureGenerator: $signatureGenerator, + store: Arr::get($deduplication, 'store', config('cache.default')), + prefix: Arr::get($deduplication, 'prefix', 'github-monolog:'), + ttl: $this->getDeduplicationTime($config), level: Arr::get($config, 'level', Level::Error), - bubble: true, bufferLimit: Arr::get($config, 'buffer.limit', 0), flushOnOverflow: Arr::get($config, 'buffer.flush_on_overflow', true) ); } - protected function createStore(array $config): StoreInterface - { - $deduplication = Arr::get($config, 'deduplication', []); - $driver = Arr::get($deduplication, 'store', 'file'); - $time = $this->getDeduplicationTime($config); - $prefix = Arr::get($deduplication, 'prefix', 'github-monolog:'); - $connection = Arr::get($deduplication, 'connection', 'default'); - $table = Arr::get($deduplication, 'table', 'github_monolog_deduplication'); - $path = Arr::get($deduplication, 'path', storage_path('logs/github-monolog-deduplication.log')); - - return match ($driver) { - 'redis' => new RedisStore(prefix: $prefix, time: $time, connection: $connection), - 'database' => new DatabaseStore(time: $time, table: $table, connection: $connection), - default => new FileStore(path: $path, time: $time), - }; - } - protected function getDeduplicationTime(array $config): int { $time = Arr::get($config, 'deduplication.time', 60); diff --git a/src/GithubMonologServiceProvider.php b/src/GithubMonologServiceProvider.php new file mode 100644 index 0000000..b174e5b --- /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..66e5737 100644 --- a/src/Issues/Formatter.php +++ b/src/Issues/Formatter.php @@ -1,30 +1,18 @@ extra['github_issue_signature'])) { @@ -34,26 +22,17 @@ public function format(LogRecord $record): Formatted $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 +40,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..aa89e67 --- /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..6469a64 --- /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 9af97f9..6069ad0 100644 --- a/src/Issues/Handler.php +++ b/src/Issues/Handler.php @@ -2,15 +2,20 @@ namespace Naoray\LaravelGithubMonolog\Issues; +use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; use Monolog\Handler\AbstractProcessingHandler; use Monolog\Level; use Monolog\LogRecord; +use Naoray\LaravelGithubMonolog\Issues\Formatters\Formatted; class Handler extends AbstractProcessingHandler { private const DEFAULT_LABEL = 'github-issue-logger'; + private PendingRequest $client; + /** * @param string $repo The GitHub repository in "owner/repo" format * @param string $token Your GitHub Personal Access Token @@ -30,6 +35,7 @@ public function __construct( $this->repo = $repo; $this->token = $token; $this->labels = array_unique(array_merge([self::DEFAULT_LABEL], $labels)); + $this->client = Http::withToken($this->token)->baseUrl('https://api.github.com'); } /** @@ -42,15 +48,24 @@ protected function write(LogRecord $record): void } $formatted = $record->formatted; - $existingIssue = $this->findExistingIssue($record); - if ($existingIssue) { - $this->commentOnIssue($existingIssue['number'], $formatted); + try { + $existingIssue = $this->findExistingIssue($record); - return; - } + if ($existingIssue) { + $this->commentOnIssue($existingIssue['number'], $formatted); + + return; + } + + $this->createIssue($formatted); + } catch (RequestException $e) { + if ($e->response->serverError()) { + throw $e; + } - $this->createIssue($formatted); + $this->createFallbackIssue($formatted, $e->response->body()); + } } /** @@ -62,16 +77,12 @@ private function findExistingIssue(LogRecord $record): ?array throw new \RuntimeException('Record is missing github_issue_signature in extra data. Make sure the DeduplicationHandler is configured correctly.'); } - $response = Http::withToken($this->token) - ->get('https://api.github.com/search/issues', [ + return $this->client + ->get('/search/issues', [ 'q' => "repo:{$this->repo} is:issue is:open label:".self::DEFAULT_LABEL." \"Signature: {$record->extra['github_issue_signature']}\"", - ]); - - if ($response->failed()) { - throw new \RuntimeException('Failed to search GitHub issues: '.$response->body()); - } - - return $response->json('items.0', null); + ]) + ->throw() + ->json('items.0', null); } /** @@ -79,14 +90,11 @@ private function findExistingIssue(LogRecord $record): ?array */ private function commentOnIssue(int $issueNumber, Formatted $formatted): void { - $response = Http::withToken($this->token) - ->post("https://api.github.com/repos/{$this->repo}/issues/{$issueNumber}/comments", [ + $this->client + ->post("/repos/{$this->repo}/issues/{$issueNumber}/comments", [ 'body' => $formatted->comment, - ]); - - if ($response->failed()) { - throw new \RuntimeException('Failed to comment on GitHub issue: '.$response->body()); - } + ]) + ->throw(); } /** @@ -94,15 +102,26 @@ private function commentOnIssue(int $issueNumber, Formatted $formatted): void */ private function createIssue(Formatted $formatted): void { - $response = Http::withToken($this->token) - ->post("https://api.github.com/repos/{$this->repo}/issues", [ + $this->client + ->post("/repos/{$this->repo}/issues", [ 'title' => $formatted->title, 'body' => $formatted->body, 'labels' => $this->labels, - ]); + ]) + ->throw(); + } - if ($response->failed()) { - throw new \RuntimeException('Failed to create GitHub issue: '.$response->body()); - } + /** + * Create a fallback issue when the main issue creation fails + */ + private function createFallbackIssue(Formatted $formatted, string $errorMessage): void + { + $this->client + ->post("/repos/{$this->repo}/issues", [ + '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']), + ]) + ->throw(); } } diff --git a/src/Issues/StubLoader.php b/src/Issues/StubLoader.php new file mode 100644 index 0000000..eda24f3 --- /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/tests/Deduplication/CacheManagerTest.php b/tests/Deduplication/CacheManagerTest.php new file mode 100644 index 0000000..09399e3 --- /dev/null +++ b/tests/Deduplication/CacheManagerTest.php @@ -0,0 +1,56 @@ +store = 'array'; + $this->prefix = 'test:'; + $this->ttl = 60; + + $this->manager = new CacheManager( + store: $this->store, + prefix: $this->prefix, + ttl: $this->ttl + ); + + Cache::store($this->store)->clear(); +}); + +afterEach(function () { + Carbon::setTestNow(); + Cache::store($this->store)->clear(); +}); + +test('it can add and check signatures', function () { + $signature = 'test-signature'; + + expect($this->manager->has($signature))->toBeFalse(); + + $this->manager->add($signature); + + expect($this->manager->has($signature))->toBeTrue(); +}); + +test('it expires old entries', function () { + $signature = 'test-signature'; + $this->manager->add($signature); + + // Travel forward in time past TTL + travel($this->ttl + 1)->seconds(); + + expect($this->manager->has($signature))->toBeFalse(); +}); + +test('it keeps valid entries', function () { + $signature = 'test-signature'; + $this->manager->add($signature); + + // Travel forward in time but not past TTL + travel($this->ttl - 1)->seconds(); + + expect($this->manager->has($signature))->toBeTrue(); +}); diff --git a/tests/Deduplication/DeduplicationHandlerTest.php b/tests/Deduplication/DeduplicationHandlerTest.php index d54634e..45c2949 100644 --- a/tests/Deduplication/DeduplicationHandlerTest.php +++ b/tests/Deduplication/DeduplicationHandlerTest.php @@ -1,27 +1,23 @@ testHandler = new TestHandler; - $this->tempFile = sys_get_temp_dir().'/dedup-test-'.uniqid().'.log'; -}); - -afterEach(function () { - @unlink($this->tempFile); + Cache::store('array')->clear(); }); test('deduplication respects time window', function () { - $store = new FileStore($this->tempFile, time: 1); $handler = new DeduplicationHandler( handler: $this->testHandler, - store: $store, signatureGenerator: new DefaultSignatureGenerator, + store: 'array', + ttl: 1 ); $record = createLogRecord(); @@ -38,11 +34,10 @@ }); test('deduplicates records with same signature', function () { - $store = new FileStore($this->tempFile); $handler = new DeduplicationHandler( handler: $this->testHandler, - store: $store, - signatureGenerator: new DefaultSignatureGenerator + signatureGenerator: new DefaultSignatureGenerator, + store: 'array' ); $record = createLogRecord(); @@ -54,11 +49,10 @@ }); test('different messages create different signatures', function () { - $store = new FileStore($this->tempFile); $handler = new DeduplicationHandler( handler: $this->testHandler, - store: $store, - signatureGenerator: new DefaultSignatureGenerator + signatureGenerator: new DefaultSignatureGenerator, + store: 'array' ); $record1 = createLogRecord('First message'); diff --git a/tests/Deduplication/Stores/AbstractStoreTest.php b/tests/Deduplication/Stores/AbstractStoreTest.php deleted file mode 100644 index 0ed3d6d..0000000 --- a/tests/Deduplication/Stores/AbstractStoreTest.php +++ /dev/null @@ -1,76 +0,0 @@ -createStore(); - $record = $this->createLogRecord(); - - $store->add($record, 'test-signature'); - $entries = $store->get(); - - $this->assertCount(1, $entries); - $this->assertStringEndsWith('test-signature', $entries[0]); - } - - #[Test] - public function it_removes_expired_entries(): void - { - $store = $this->createStore(time: 1); // 1 second expiry - $record = $this->createLogRecord(); - - $store->add($record, 'test-signature'); - sleep(2); // Wait for entry to expire - - $this->assertEmpty($store->get()); - } - - #[Test] - public function it_keeps_valid_entries(): void - { - $store = $this->createStore(time: 5); - $record = $this->createLogRecord(); - - $store->add($record, 'test-signature'); - $entries = $store->get(); - - $this->assertCount(1, $entries); - } - - #[Test] - public function it_handles_multiple_entries(): void - { - $store = $this->createStore(); - $record1 = $this->createLogRecord('test1'); - $record2 = $this->createLogRecord('test2'); - - $store->add($record1, 'signature1'); - $store->add($record2, 'signature2'); - - $entries = $store->get(); - $this->assertCount(2, $entries); - } - - abstract protected function createStore(string $prefix = 'test:', int $time = 60): mixed; -} diff --git a/tests/Deduplication/Stores/DatabaseStoreTest.php b/tests/Deduplication/Stores/DatabaseStoreTest.php deleted file mode 100644 index 44beaa9..0000000 --- a/tests/Deduplication/Stores/DatabaseStoreTest.php +++ /dev/null @@ -1,98 +0,0 @@ -set('database.default', 'sqlite'); - config()->set('database.connections.sqlite', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); -}); - -afterEach(function () { - Schema::dropIfExists('github_monolog_deduplication'); - Schema::dropIfExists('custom_dedup'); -}); - -// Base Store Tests -test('it can add and retrieve entries', function () { - $store = createDatabaseStore(); - $record = createLogRecord(); - $signature = 'test-signature'; - - $store->add($record, $signature); - - expect($store->get())->toHaveCount(1); -}); - -test('it removes expired entries', function () { - $store = createDatabaseStore(time: 1); - $record = createLogRecord(); - $signature = 'test-signature'; - - $store->add($record, $signature); - travel(2)->seconds(); - - expect($store->get())->toBeEmpty(); -}); - -test('it keeps valid entries', function () { - $store = createDatabaseStore(); - $record = createLogRecord(); - $signature = 'test-signature'; - - $store->add($record, $signature); - - expect($store->get())->toHaveCount(1); -}); - -test('it handles multiple entries', function () { - $store = createDatabaseStore(); - $record = createLogRecord(); - - $store->add($record, 'signature-1'); - $store->add($record, 'signature-2'); - - expect($store->get())->toHaveCount(2); -}); - -// DatabaseStore Specific Tests -test('it creates table if not exists', function () { - $store = createDatabaseStore(); - expect(Schema::connection('sqlite')->hasTable('github_monolog_deduplication'))->toBeTrue(); -}); - -test('it can use custom table', function () { - $store = new DatabaseStore( - connection: 'sqlite', - table: 'custom_dedup', - time: 60 - ); - - $record = createLogRecord(); - $signature = 'test-signature'; - - $store->add($record, $signature); - - expect($store->get())->toHaveCount(1); - expect(Schema::connection('sqlite')->hasTable('custom_dedup'))->toBeTrue(); -}); - -test('it cleans up expired entries from database', function () { - $store = createDatabaseStore(time: 1); - $record = createLogRecord(); - - $store->add($record, 'signature-1'); - $store->add($record, 'signature-2'); - travel(2)->seconds(); - - $store->cleanup(); - - expect($store->get())->toBeEmpty(); -}); diff --git a/tests/Deduplication/Stores/FileStoreTest.php b/tests/Deduplication/Stores/FileStoreTest.php deleted file mode 100644 index 0182358..0000000 --- a/tests/Deduplication/Stores/FileStoreTest.php +++ /dev/null @@ -1,120 +0,0 @@ -testPath = storage_path('logs/test-dedup.log'); - File::delete($this->testPath); -}); - -afterEach(function () { - File::delete($this->testPath); -}); - -// Base Store Tests -test('it can add and retrieve entries', function () { - $store = createFileStore(); - $record = createLogRecord(); - - $store->add($record, 'test-signature'); - $entries = $store->get(); - - expect($entries)->toHaveCount(1) - ->and($entries[0])->toEndWith('test-signature'); -}); - -test('it removes expired entries', function () { - $store = createFileStore(time: 1); - $record = createLogRecord(); - - $store->add($record, 'test-signature'); - - travel(2)->seconds(); - - $store->cleanup(); - expect($store->get())->toBeEmpty(); -}); - -test('it keeps valid entries', function () { - $store = createFileStore(time: 5); - $record = createLogRecord(); - - $store->add($record, 'test-signature'); - $entries = $store->get(); - - expect($entries)->toHaveCount(1); -}); - -test('it handles multiple entries', function () { - $store = createFileStore(); - $record1 = createLogRecord('test1'); - $record2 = createLogRecord('test2'); - - $store->add($record1, 'signature1'); - $store->add($record2, 'signature2'); - - expect($store->get())->toHaveCount(2); -}); - -// FileStore Specific Tests -test('it creates directory if not exists', function () { - $path = storage_path('logs/nested/test-dedup.log'); - File::deleteDirectory(dirname($path)); - - new FileStore($path); - - expect(File::exists(dirname($path)))->toBeTrue(); - File::deleteDirectory(dirname($path)); -}); - -test('it handles concurrent access', function () { - $store1 = createFileStore(); - $store2 = createFileStore(); - $record = createLogRecord(); - - $store1->add($record, 'signature1'); - $store2->add($record, 'signature2'); - - expect($store1->get())->toHaveCount(2); -}); - -test('it handles corrupted file', function () { - File::put(test()->testPath, 'invalid content'); - - $store = createFileStore(); - $record = createLogRecord(); - - $store->add($record, 'test-signature'); - $entries = $store->get(); - - expect($entries)->not->toBeEmpty() - ->and($entries[array_key_first($entries)])->toEndWith('test-signature'); -}); - -test('it handles file permissions', function () { - $store = createFileStore(); - $record = createLogRecord(); - - $store->add($record, 'test-signature'); - - expect(substr(sprintf('%o', fileperms(test()->testPath)), -4))->toBe('0644'); -}); - -test('it maintains file integrity after cleanup', function () { - $store = createFileStore(time: 1); - $record = createLogRecord(); - - $store->add($record, 'signature1'); - travel(2)->seconds(); - $store->add($record, 'signature2'); - - $store->cleanup(); - - $content = File::get(test()->testPath); - - expect(explode(PHP_EOL, trim($content)))->toHaveCount(1) - ->and($content)->toEndWith('signature2'); -}); diff --git a/tests/Deduplication/Stores/RedisStoreTest.php b/tests/Deduplication/Stores/RedisStoreTest.php deleted file mode 100644 index 48ab731..0000000 --- a/tests/Deduplication/Stores/RedisStoreTest.php +++ /dev/null @@ -1,124 +0,0 @@ -set('database.redis.default', [ - 'host' => env('REDIS_HOST', '127.0.0.1'), - 'password' => env('REDIS_PASSWORD'), - 'port' => env('REDIS_PORT', 6379), - 'database' => 0, - ]); - - // Configure second Redis connection for testing - config()->set('database.redis.other', [ - 'host' => env('REDIS_HOST', '127.0.0.1'), - 'password' => env('REDIS_PASSWORD'), - 'port' => env('REDIS_PORT', 6379), - 'database' => 1, - ]); - - // Clear test keys - Redis::del('test:dedup'); - Redis::del('custom:dedup'); - Redis::connection('other')->del('test:dedup'); -}); - -afterEach(function () { - Redis::del('test:dedup'); - Redis::del('custom:dedup'); - Redis::connection('other')->del('test:dedup'); -}); - -// Base Store Tests -test('it can add and retrieve entries', function () { - $store = createRedisStore(); - $record = createLogRecord(); - - $store->add($record, 'test-signature'); - $entries = $store->get(); - - expect($entries)->toHaveCount(1) - ->and($entries[0])->toEndWith('test-signature'); -}); - -test('it removes expired entries', function () { - $store = createRedisStore(time: 1); - $record = createLogRecord(); - - $store->add($record, 'test-signature'); - - travel(2)->seconds(); - - $store->cleanup(); - - expect($store->get())->toBeEmpty(); -}); - -test('it keeps valid entries', function () { - $store = createRedisStore(time: 5); - $record = createLogRecord(); - - $store->add($record, 'test-signature'); - $entries = $store->get(); - - expect($entries)->toHaveCount(1); -}); - -test('it handles multiple entries', function () { - $store = createRedisStore(); - $record1 = createLogRecord('test1'); - $record2 = createLogRecord('test2'); - - $store->add($record1, 'signature1'); - $store->add($record2, 'signature2'); - - expect($store->get())->toHaveCount(2); -}); - -// Redis Store Specific Tests -test('it uses correct redis key', function () { - $store = createRedisStore('custom:'); - $record = createLogRecord(); - - $store->add($record, 'test-signature'); - - expect(Redis::exists('custom:dedup'))->toBeGreaterThan(0); -}); - -test('it can use different redis connection', function () { - $store = new RedisStore( - connection: 'other', - prefix: 'test:', - time: 60 - ); - - $record = createLogRecord(); - $store->add($record, 'test-signature'); - - expect(Redis::connection('other')->exists('test:dedup'))->toBeGreaterThan(0); -}); - -test('it properly cleans up expired entries', function () { - $store = createRedisStore(time: 1); - $record = createLogRecord(); - - // Add an entry that will expire - $store->add($record, 'test-signature'); - - // Verify it exists - expect($store->get())->toHaveCount(1); - - travel(2)->seconds(); - - $store->cleanup(); - - // Verify Redis directly - expect(Redis::zcount($store->getKey(), '-inf', '+inf')) - ->toBe(0) - ->and($store->get())->toBeEmpty(); -}); diff --git a/tests/GithubIssueHandlerFactoryTest.php b/tests/GithubIssueHandlerFactoryTest.php index c270ee3..62c6eb8 100644 --- a/tests/GithubIssueHandlerFactoryTest.php +++ b/tests/GithubIssueHandlerFactoryTest.php @@ -4,11 +4,8 @@ use Monolog\Logger; use Naoray\LaravelGithubMonolog\Deduplication\DeduplicationHandler; use Naoray\LaravelGithubMonolog\Deduplication\DefaultSignatureGenerator; -use Naoray\LaravelGithubMonolog\Deduplication\Stores\DatabaseStore; -use Naoray\LaravelGithubMonolog\Deduplication\Stores\FileStore; -use Naoray\LaravelGithubMonolog\Deduplication\Stores\RedisStore; 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 @@ -18,9 +15,9 @@ function getWrappedHandler(DeduplicationHandler $handler): Handler return $reflection->getValue($handler); } -function getDeduplicationStore(DeduplicationHandler $handler): mixed +function getCacheManager(DeduplicationHandler $handler): mixed { - $reflection = new ReflectionProperty($handler, 'store'); + $reflection = new ReflectionProperty($handler, 'cache'); return $reflection->getValue($handler); } @@ -40,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 () { @@ -65,7 +61,7 @@ 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 () { @@ -91,62 +87,45 @@ function getSignatureGenerator(DeduplicationHandler $handler): mixed ->and($flushOnOverflow)->toBeFalse(); }); -test('it can use file store driver', function () { - $path = sys_get_temp_dir().'/dedup-test-'.uniqid().'.log'; - +test('it uses configured cache store', function () { $logger = ($this->factory)([ ...$this->config, 'deduplication' => [ - 'store' => 'file', - 'path' => $path, + 'store' => 'array', + 'prefix' => 'custom:', + 'time' => 120, ], ]); /** @var DeduplicationHandler $handler */ $handler = $logger->getHandlers()[0]; - $store = getDeduplicationStore($handler); - - expect($store)->toBeInstanceOf(FileStore::class); -}); - -test('it can use redis store driver', function () { - $this->config['deduplication'] = [ - 'store' => 'redis', - 'connection' => 'default', - ]; - - $logger = ($this->factory)($this->config); - - /** @var DeduplicationHandler $handler */ - $handler = $logger->getHandlers()[0]; - $store = getDeduplicationStore($handler); - - expect($store)->toBeInstanceOf(RedisStore::class); + $cacheManager = getCacheManager($handler); + $store = (new ReflectionProperty($cacheManager, 'store'))->getValue($cacheManager); + $prefix = (new ReflectionProperty($cacheManager, 'prefix'))->getValue($cacheManager); + $ttl = (new ReflectionProperty($cacheManager, 'ttl'))->getValue($cacheManager); + + expect($store)->toBe('array') + ->and($prefix)->toBe('custom:') + ->and($ttl)->toBe(120); }); -test('it can use database store driver', function () { - config()->set('database.connections.sqlite', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - ]); - - $this->config['deduplication'] = [ - 'store' => 'database', - 'connection' => 'sqlite', - ]; - +test('it uses default cache configuration', function () { $logger = ($this->factory)($this->config); /** @var DeduplicationHandler $handler */ $handler = $logger->getHandlers()[0]; - $store = getDeduplicationStore($handler); - - expect($store)->toBeInstanceOf(DatabaseStore::class); + $cacheManager = getCacheManager($handler); + $store = (new ReflectionProperty($cacheManager, 'store'))->getValue($cacheManager); + $prefix = (new ReflectionProperty($cacheManager, 'prefix'))->getValue($cacheManager); + $ttl = (new ReflectionProperty($cacheManager, 'ttl'))->getValue($cacheManager); + + expect($store)->toBe(config('cache.default')) + ->and($prefix)->toBe('github-monolog:') + ->and($ttl)->toBe(60); }); 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', ]); @@ -178,13 +157,3 @@ function getSignatureGenerator(DeduplicationHandler $handler): mixed ], ]))->toThrow(\InvalidArgumentException::class, 'Deduplication time must be a positive integer'); }); - -test('it uses file store driver by default', function () { - $logger = ($this->factory)($this->config); - - /** @var DeduplicationHandler $handler */ - $handler = $logger->getHandlers()[0]; - $store = getDeduplicationStore($handler); - - expect($store)->toBeInstanceOf(FileStore::class); -}); diff --git a/tests/Issues/Formatters/ExceptionFormatterTest.php b/tests/Issues/Formatters/ExceptionFormatterTest.php new file mode 100644 index 0000000..d99a26c --- /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..d5b7bb0 --- /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 339319d..4f92749 100644 --- a/tests/Issues/HandlerTest.php +++ b/tests/Issues/HandlerTest.php @@ -2,10 +2,12 @@ namespace Tests\Issues; +use Illuminate\Http\Client\Request; +use Illuminate\Http\Client\RequestException; 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 @@ -18,7 +20,7 @@ function createHandler(): Handler bubble: true ); - $handler->setFormatter(new Formatter); + $handler->setFormatter(app()->make(IssueFormatter::class)); return $handler; } @@ -35,91 +37,118 @@ function createRecord(): LogRecord ); } -test('it creates new github issue when no duplicate exists', function () { - $handler = createHandler(); - $record = createRecord(); +beforeEach(function () { + Http::preventStrayRequests(); +}); +test('it creates new github issue when no duplicate exists', function () { Http::fake([ 'github.com/search/issues*' => Http::response(['items' => []]), 'github.com/repos/test/repo/issues' => Http::response(['number' => 1]), ]); + $handler = createHandler(); + $record = createRecord(); + $handler->handle($record); - Http::assertSent(function ($request) { - return str_contains($request->url(), '/repos/test/repo/issues') - && $request->method() === 'POST'; + Http::assertSent(function (Request $request) { + return str($request->url())->endsWith('/repos/test/repo/issues'); }); }); test('it comments on existing github issue', function () { - $handler = createHandler(); - $record = createRecord(); - Http::fake([ 'github.com/search/issues*' => Http::response(['items' => [['number' => 1]]]), 'github.com/repos/test/repo/issues/1/comments' => Http::response(['id' => 1]), ]); + $handler = createHandler(); + $record = createRecord(); + $handler->handle($record); Http::assertSent(function ($request) { - return str_contains($request->url(), '/issues/1/comments') - && $request->method() === 'POST'; + return str($request->url())->endsWith('/issues/1/comments'); }); }); test('it includes signature in issue search', function () { - $handler = createHandler(); - $record = createRecord(); - Http::fake([ 'github.com/search/issues*' => Http::response(['items' => []]), 'github.com/repos/test/repo/issues' => Http::response(['number' => 1]), ]); + $handler = createHandler(); + $record = createRecord(); + $handler->handle($record); Http::assertSent(function ($request) { - return str_contains($request->url(), '/search/issues') - && str_contains($request['q'], 'test-signature'); + return str($request->url())->contains('/search/issues') + && str_contains($request->data()['q'], 'test-signature'); }); }); test('it throws exception when issue search fails', function () { - $handler = createHandler(); - $record = createRecord(); - Http::fake([ 'github.com/search/issues*' => Http::response(['error' => 'Failed'], 500), ]); - expect(fn () => $handler->handle($record)) - ->toThrow('Failed to search GitHub issues'); -}); - -test('it throws exception when issue creation fails', function () { $handler = createHandler(); $record = createRecord(); + $handler->handle($record); +})->throws(RequestException::class, exceptionCode: 500); + +test('it throws exception when issue creation fails', function () { Http::fake([ 'github.com/search/issues*' => Http::response(['items' => []]), 'github.com/repos/test/repo/issues' => Http::response(['error' => 'Failed'], 500), ]); - expect(fn () => $handler->handle($record)) - ->toThrow('Failed to create GitHub issue'); -}); - -test('it throws exception when comment creation fails', function () { $handler = createHandler(); $record = createRecord(); + $handler->handle($record); +})->throws(RequestException::class, exceptionCode: 500); + +test('it throws exception when comment creation fails', function () { Http::fake([ 'github.com/search/issues*' => Http::response(['items' => [['number' => 1]]]), 'github.com/repos/test/repo/issues/1/comments' => Http::response(['error' => 'Failed'], 500), ]); - expect(fn () => $handler->handle($record)) - ->toThrow('Failed to comment on GitHub issue'); + $handler = createHandler(); + $record = createRecord(); + + $handler->handle($record); +})->throws(RequestException::class, exceptionCode: 500); + +test('it creates fallback issue when 4xx error occurs', function () { + $errorMessage = 'Validation failed for the issue'; + + Http::fake([ + 'github.com/search/issues*' => Http::response(['items' => []]), + 'github.com/repos/test/repo/issues' => Http::sequence() + ->push(['error' => $errorMessage], 422) + ->push(['number' => 1]), + ]); + + $handler = createHandler(); + $record = createRecord(); + + $handler->handle($record); + + Http::assertSent(function ($request) { + return str($request->url())->endsWith('/repos/test/repo/issues') + && ! str_contains($request->data()['title'], '[GitHub Monolog Error]'); + }); + + Http::assertSent(function ($request) use ($errorMessage) { + return str($request->url())->endsWith('/repos/test/repo/issues') + && str_contains($request->data()['title'], '[GitHub Monolog Error]') + && str_contains($request->data()['body'], $errorMessage) + && in_array('monolog-integration-error', $request->data()['labels']); + }); }); diff --git a/tests/Issues/StubLoaderTest.php b/tests/Issues/StubLoaderTest.php new file mode 100644 index 0000000..05cdb5f --- /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..008e5da --- /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/Pest.php b/tests/Pest.php index 71380e7..3c931bd 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,9 +2,6 @@ use Monolog\Level; use Monolog\LogRecord; -use Naoray\LaravelGithubMonolog\Deduplication\Stores\DatabaseStore; -use Naoray\LaravelGithubMonolog\Deduplication\Stores\FileStore; -use Naoray\LaravelGithubMonolog\Deduplication\Stores\RedisStore; use Naoray\LaravelGithubMonolog\Tests\TestCase; uses(TestCase::class)->in(__DIR__); @@ -20,29 +17,3 @@ function createLogRecord(string $message = 'Test', array $context = [], Level $l extra: [], ); } - -function createDatabaseStore(int $time = 60): DatabaseStore -{ - return new DatabaseStore( - connection: 'sqlite', - table: 'github_monolog_deduplication', - time: $time - ); -} - -function createFileStore(int $time = 60): FileStore -{ - return new FileStore( - path: test()->testPath, - time: $time - ); -} - -function createRedisStore(string $prefix = 'test:', int $time = 60): RedisStore -{ - return new RedisStore( - connection: 'default', - prefix: $prefix, - time: $time - ); -} diff --git a/tests/TestCase.php b/tests/TestCase.php index 4d188d7..0071a29 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,9 +2,33 @@ 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 + $app['config']->set('cache.default', 'array'); + $app['config']->set('cache.stores.array', [ + 'driver' => 'array', + 'serialize' => false, + ]); + + // Configure database for testing + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } }