diff --git a/README.md b/README.md index 4b9ffca..18f0d46 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,16 @@ A Laravel package that automatically creates GitHub issues from your application ## Features -- 😊 Automatically creates GitHub issues from log entries -- 🔍 Groups similar errors into the same issue -- 💬 Adds comments to existing issues when the same error occurs again -- 🏷️ Customizable labels for better organization +- ✨ Automatically creates GitHub issues from log entries +- 🔍 Intelligently groups similar errors into single issues +- 💬 Adds comments to existing issues for recurring errors +- 🏷️ Supports customizable labels for efficient organization +- 🎯 Smart deduplication to prevent issue spam +- ⚡️ Buffered logging for better performance ## Showcase -When an error occurs in your application, a new GitHub issue is automatically created with detailed error information and stack trace: +When an error occurs in your application, a GitHub issue is automatically created with comprehensive error information and stack trace: issue raised @@ -40,12 +42,19 @@ Add the GitHub logging channel to your `config/logging.php`: // ... other channels ... 'github' => [ + // Required configuration 'driver' => 'custom', 'via' => \Naoray\LaravelGithubMonolog\GithubIssueHandlerFactory::class, - 'level' => env('LOG_LEVEL', 'error'), 'repo' => env('GITHUB_REPO'), // Format: "username/repository" 'token' => env('GITHUB_TOKEN'), // Your GitHub Personal Access Token - 'labels' => ['bug'], // Optional: Additional labels for issues + + // Optional configuration + 'level' => env('LOG_LEVEL', 'error'), + 'labels' => ['bug'], + 'deduplication' => [ + 'store' => storage_path('logs/github-issues-dedup.log'), // Custom path for deduplication storage + 'time' => 60, // Time in seconds to consider logs as duplicates + ], ], ] ``` @@ -57,21 +66,25 @@ GITHUB_REPO=username/repository GITHUB_TOKEN=your-github-personal-access-token ``` +You can use the `github` log channel as your default `LOG_CHANNEL` or add it as part of your stack in `LOG_STACK`. + ### Getting a GitHub Token To obtain a Personal Access Token: 1. Go to [Generate a new token](https://github.com/settings/tokens/new?description=Laravel%20GitHub%20Issue%20Logger&scopes=repo) (this link pre-selects the required scopes) -2. Review the pre-selected scopes (you should see `repo` checked) +2. Review the pre-selected scopes (the `repo` scope should be checked) 3. Click "Generate token" -4. Copy the token immediately (you won't be able to see it again!) +4. Copy the token immediately (you won't be able to access it again after leaving the page) 5. Add it to your `.env` file as `GITHUB_TOKEN` -> **Note**: The token needs the `repo` scope to create issues in both public and private repositories. +> **Note**: The token requires the `repo` scope to create issues in both public and private repositories. ## Usage -Use it like any other Laravel logging channel: +Whenever an exception is thrown it will be logged as an issue to your repository. + +You can also use it like any other Laravel logging channel: ```php // Single channel @@ -81,7 +94,21 @@ Log::channel('github')->error('Something went wrong!'); Log::stack(['daily', 'github'])->error('Something went wrong!'); ``` -Each unique error will create a new GitHub issue. If the same error occurs again, it will be added as a comment to the existing issue instead of creating a duplicate. +### Deduplication + +The package includes smart deduplication to prevent your repository from being flooded with duplicate issues: + +1. **Time-based Deduplication**: Similar errors within the configured time window (default: 60 seconds) are considered duplicates +2. **Intelligent Grouping**: Uses error signatures to group similar errors, even if the exact details differ +3. **Automatic Storage**: Deduplication data is automatically stored in your Laravel logs directory +4. **Configurable**: Customize both the storage location and deduplication time window + +For example, if your application encounters the same error multiple times in quick succession: +- First occurrence: Creates a new GitHub issue +- Subsequent occurrences within the deduplication window: No new issues created +- After the deduplication window: Creates a new issue or adds a comment to the existing one + +This helps keep your GitHub issues organized and prevents notification spam during error storms. ## Testing diff --git a/composer.json b/composer.json index 8ecce00..98406a9 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,8 @@ "php": "^8.3", "illuminate/contracts": "^10.0||^11.0", "illuminate/http": "^10.0||^11.0", - "illuminate/support": "^10.0||^11.0" + "illuminate/support": "^10.0||^11.0", + "illuminate/filesystem": "^10.0||^11.0" }, "require-dev": { "laravel/pint": "^1.14", diff --git a/src/GithubIssueHandlerFactory.php b/src/GithubIssueHandlerFactory.php index 2472cc3..4a47c9a 100644 --- a/src/GithubIssueHandlerFactory.php +++ b/src/GithubIssueHandlerFactory.php @@ -3,13 +3,25 @@ namespace Naoray\LaravelGithubMonolog; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\File; use InvalidArgumentException; +use Monolog\Handler\DeduplicationHandler; use Monolog\Level; use Monolog\Logger; class GithubIssueHandlerFactory { public function __invoke(array $config): Logger + { + $this->validateConfig($config); + + $handler = $this->createBaseHandler($config); + $deduplicationHandler = $this->wrapWithDeduplication($handler, $config); + + return new Logger('github', [$deduplicationHandler]); + } + + protected function validateConfig(array $config): void { if (! Arr::has($config, 'repo')) { throw new InvalidArgumentException('GitHub repository is required'); @@ -18,7 +30,10 @@ public function __invoke(array $config): Logger if (! Arr::has($config, 'token')) { throw new InvalidArgumentException('GitHub token is required'); } + } + protected function createBaseHandler(array $config): GithubIssueLoggerHandler + { $handler = new GithubIssueLoggerHandler( repo: $config['repo'], token: $config['token'], @@ -29,6 +44,36 @@ public function __invoke(array $config): Logger $handler->setFormatter(new GithubIssueFormatter); - return new Logger('github', [$handler]); + return $handler; + } + + protected function wrapWithDeduplication(GithubIssueLoggerHandler $handler, array $config): DeduplicationHandler + { + return new DeduplicationHandler( + $handler, + deduplicationStore: $this->getDeduplicationStore($config), + deduplicationLevel: Arr::get($config, 'level', Level::Error), + time: $this->getDeduplicationTime($config), + bubble: true + ); + } + + protected function getDeduplicationStore(array $config): string + { + $deduplication = Arr::get($config, 'deduplication', []); + + if ($store = Arr::get($deduplication, 'store')) { + return $store; + } + + $store = storage_path('logs/github-issues-dedup.log'); + File::ensureDirectoryExists(dirname($store)); + + return $store; + } + + protected function getDeduplicationTime(array $config): int + { + return (int) Arr::get($config, 'deduplication.time', 60); } } diff --git a/tests/GithubIssueHandlerFactoryTest.php b/tests/GithubIssueHandlerFactoryTest.php index ccf5c11..2dc4679 100644 --- a/tests/GithubIssueHandlerFactoryTest.php +++ b/tests/GithubIssueHandlerFactoryTest.php @@ -1,10 +1,28 @@ setAccessible(true); + + return $reflection->getValue($handler); +} + +function getDeduplicationStore(DeduplicationHandler $handler): string +{ + $reflection = new ReflectionProperty($handler, 'deduplicationStore'); + $reflection->setAccessible(true); + + return $reflection->getValue($handler); +} test('it creates logger with correct configuration', function () { $config = [ @@ -21,7 +39,13 @@ ->toBeInstanceOf(Logger::class) ->and($logger->getName())->toBe('github') ->and($logger->getHandlers()[0]) - ->toBeInstanceOf(GithubIssueLoggerHandler::class); + ->toBeInstanceOf(DeduplicationHandler::class); + + /** @var DeduplicationHandler $deduplicationHandler */ + $deduplicationHandler = $logger->getHandlers()[0]; + $handler = getWrappedHandler($deduplicationHandler); + + expect($handler)->toBeInstanceOf(GithubIssueLoggerHandler::class); }); test('it throws exception for missing required config', function () { @@ -42,14 +66,20 @@ $factory = new GithubIssueHandlerFactory; $logger = $factory($config); - /** @var GithubIssueLoggerHandler $handler */ - $handler = $logger->getHandlers()[0]; + + /** @var DeduplicationHandler $deduplicationHandler */ + $deduplicationHandler = $logger->getHandlers()[0]; + $handler = getWrappedHandler($deduplicationHandler); expect($handler) ->toBeInstanceOf(GithubIssueLoggerHandler::class) ->and($handler->getLevel())->toBe(Level::Error) ->and($handler->getBubble())->toBeTrue() ->and($handler->getFormatter())->toBeInstanceOf(GithubIssueFormatter::class); + + // Verify default deduplication store path + expect(getDeduplicationStore($deduplicationHandler)) + ->toBe(storage_path('logs/github-issues-dedup.log')); }); test('it accepts custom log level', function () { @@ -61,8 +91,34 @@ $factory = new GithubIssueHandlerFactory; $logger = $factory($config); - /** @var GithubIssueLoggerHandler $handler */ - $handler = $logger->getHandlers()[0]; + + /** @var DeduplicationHandler $deduplicationHandler */ + $deduplicationHandler = $logger->getHandlers()[0]; + $handler = getWrappedHandler($deduplicationHandler); expect($handler->getLevel())->toBe(Level::Debug); }); + +test('it allows custom deduplication configuration', function () { + $config = [ + 'repo' => 'test/repo', + 'token' => 'fake-token', + 'deduplication' => [ + 'store' => '/custom/path/dedup.log', + 'time' => 120, + ], + ]; + + $factory = new GithubIssueHandlerFactory; + $logger = $factory($config); + + /** @var DeduplicationHandler $deduplicationHandler */ + $deduplicationHandler = $logger->getHandlers()[0]; + + expect(getDeduplicationStore($deduplicationHandler)) + ->toBe('/custom/path/dedup.log'); + + $reflection = new ReflectionProperty($deduplicationHandler, 'time'); + $reflection->setAccessible(true); + expect($reflection->getValue($deduplicationHandler))->toBe(120); +});