├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── UPGRADE.md ├── composer.json ├── resources └── views │ ├── comment.md │ ├── issue.md │ └── previous_exception.md └── src ├── Deduplication ├── CacheManager.php ├── DeduplicationHandler.php ├── DefaultSignatureGenerator.php └── SignatureGeneratorInterface.php ├── GithubIssueHandlerFactory.php ├── GithubMonologServiceProvider.php ├── Issues ├── Formatter.php ├── Formatters │ ├── ExceptionFormatter.php │ ├── Formatted.php │ ├── IssueFormatter.php │ ├── PreviousExceptionFormatter.php │ └── StackTraceFormatter.php ├── Handler.php ├── InteractsWithLogRecord.php ├── SectionMapping.php ├── StubLoader.php ├── TemplateRenderer.php └── TemplateSectionCleaner.php └── Tracing ├── EventHandler.php ├── RequestDataCollector.php └── UserDataCollector.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-github-monolog` will be documented in this file. 4 | 5 | ## v3.3.0 - 2025-03-26 6 | 7 | ### What's Changed 8 | 9 | * Feat/improve stack trace formatting by @Naoray in https://github.com/Naoray/laravel-github-monolog/pull/14 10 | 11 | **Full Changelog**: https://github.com/Naoray/laravel-github-monolog/compare/v3.2.1...v3.3.0 12 | 13 | ## v3.2.1 - 2025-03-21 14 | 15 | ### What's Changed 16 | 17 | * Fix/min version requirement by @Naoray in https://github.com/Naoray/laravel-github-monolog/pull/13 18 | 19 | **Full Changelog**: https://github.com/Naoray/laravel-github-monolog/compare/v3.2.0...v3.2.1 20 | 21 | ## v3.2.0 - 2025-03-21 22 | 23 | ### What's Changed 24 | 25 | * feat: add tracing capabilities by @Naoray in https://github.com/Naoray/laravel-github-monolog/pull/12 26 | 27 | **Full Changelog**: https://github.com/Naoray/laravel-github-monolog/compare/v3.1.0...v3.2.0 28 | 29 | ## v3.1.0 - 2025-03-20 30 | 31 | ### What's Changed 32 | 33 | * fix: TypeError: DeduplicationHandler::__construct(): Argument #3 ($store) must be of type string, null given by @andrey-helldar in https://github.com/Naoray/laravel-github-monolog/pull/10 34 | * feat: enhance templates by @Naoray in https://github.com/Naoray/laravel-github-monolog/pull/11 35 | 36 | **Full Changelog**: https://github.com/Naoray/laravel-github-monolog/compare/v3.0.0...v3.1.0 37 | 38 | ## v3.0.0 - 2025-02-28 39 | 40 | ### What's Changed 41 | 42 | * remove custom store implementation in favor of laravel's cache 43 | * add customizable stubs 44 | * Added Laravel 12 support by @andrey-helldar in https://github.com/Naoray/laravel-github-monolog/pull/7 45 | 46 | s. [UPGRADE.md](https://github.com/Naoray/laravel-github-monolog/blob/main/UPGRADE.md) for an upgrade guide as this release includes a few breaking changes. 47 | 48 | ### New Contributors 49 | 50 | * @andrey-helldar made their first contribution in https://github.com/Naoray/laravel-github-monolog/pull/7 51 | 52 | **Full Changelog**: https://github.com/Naoray/laravel-github-monolog/compare/v2.1.1...v3.0.0 53 | 54 | ## v2.1.1 - 2025-01-13 55 | 56 | - fix wrong array key being used for deduplication stores (before `driver`, now `store`) 57 | - fix table config not being passed on to `DatabaseStore` 58 | 59 | **Full Changelog**: https://github.com/Naoray/laravel-github-monolog/compare/v2.1.0...v2.1.1 60 | 61 | ## v2.1.0 - 2025-01-13 62 | 63 | ### What's Changed 64 | 65 | * Feature/added deduplication stores by @Naoray in https://github.com/Naoray/laravel-github-monolog/pull/2 66 | 67 | **Full Changelog**: https://github.com/Naoray/laravel-github-monolog/compare/v2.0.1...v2.1.0 68 | 69 | ## v2.0.1 - 2025-01-12 70 | 71 | - include context in reports no matter if it's an exception being reported or just a log 72 | 73 | **Full Changelog**: https://github.com/Naoray/laravel-github-monolog/compare/v2.0.0...v2.0.1 74 | 75 | ## v2.0.0 - 2025-01-12 76 | 77 | - drop support for Laravel 10 / Monolog < 3.6.0 78 | 79 | **Full Changelog**: https://github.com/Naoray/laravel-github-monolog/compare/v1.1.0...v2.0.0 80 | 81 | ## v1.1.0 - 2025-01-12 82 | 83 | - Use our own `SignatureDeduplicationHandler` to properly handle duplicated issues before submitting them to the `IssueLogHandler` 84 | - restructure codebase 85 | 86 | **Full Changelog**: https://github.com/Naoray/laravel-github-monolog/compare/v1.0.0...v1.1.0 87 | 88 | ## v1.0.0 - Initial Release 🚀 - 2025-01-10 89 | 90 | - ✨ Automatically creates GitHub issues from log entries 91 | 92 | - 🔍 Intelligently groups similar errors into single issues 93 | 94 | - 💬 Adds comments to existing issues for recurring errors 95 | 96 | - 🏷️ Supports customizable labels for efficient organization 97 | 98 | - 🎯 Smart deduplication to prevent issue spam 99 | 100 | - Time-based deduplication (configurable window) 101 | - Prevents duplicate issues during error storms 102 | - Automatic storage management 103 | 104 | - ⚡️ Buffered logging for better performance 105 | 106 | 107 | **Full Changelog**: https://github.com/Naoray/laravel-github-monolog/commits/v1.0.0 108 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Krishan Koenig 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel GitHub Issue Logger 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/naoray/laravel-github-monolog.svg?style=flat-square)](https://packagist.org/packages/naoray/laravel-github-monolog) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/naoray/laravel-github-monolog/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/naoray/laravel-github-monolog/actions?query=workflow%3Arun-tests+branch%3Amain) 5 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/naoray/laravel-github-monolog/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/naoray/laravel-github-monolog/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/naoray/laravel-github-monolog.svg?style=flat-square)](https://packagist.org/packages/naoray/laravel-github-monolog) 7 | 8 | Automatically create GitHub issues from your Laravel exceptions & logs. Perfect for smaller projects without the need for full-featured logging services. 9 | 10 | ## Requirements 11 | 12 | - PHP ^8.3 13 | - Laravel ^11.37|^12.0 14 | - Monolog ^3.6 15 | 16 | ## Features 17 | 18 | - ✨ Automatically create GitHub issues from Exceptions & Logs 19 | - 🔍 Group similar errors into single issues 20 | - 💬 Add comments to existing issues for recurring errors 21 | - 🏷️ Support customizable labels 22 | - 🎯 Smart deduplication to prevent issue spam 23 | - ⚡️ Buffered logging for better performance 24 | - 📝 Customizable issue templates 25 | - 🕵🏻‍♂️ Tracing Support (Request & User) 26 | 27 | ## Showcase 28 | 29 | When an error occurs in your application, a GitHub issue is automatically created with comprehensive error information and stack trace: 30 | 31 | issue raised 32 | 33 | The issue appears in your repository with all the detailed information about the error: 34 | 35 | issue detail 36 | 37 | If the same error occurs again, instead of creating a duplicate, a new comment is automatically added to track the occurrence: 38 | 39 | comment added 40 | 41 | ## Installation 42 | 43 | Install with Composer: 44 | 45 | ```bash 46 | composer require naoray/laravel-github-monolog 47 | ``` 48 | 49 | ## Configuration 50 | 51 | Add the GitHub logging channel to `config/logging.php`: 52 | 53 | ```php 54 | 'channels' => [ 55 | // ... other channels ... 56 | 57 | 'github' => [ 58 | // Required configuration 59 | 'driver' => 'custom', 60 | 'via' => \Naoray\LaravelGithubMonolog\GithubIssueHandlerFactory::class, 61 | 'repo' => env('GITHUB_REPO'), // Format: "username/repository" 62 | 'token' => env('GITHUB_TOKEN'), // Your GitHub Personal Access Token 63 | 64 | // Optional configuration 65 | 'level' => env('LOG_LEVEL', 'error'), 66 | 'labels' => ['bug'], 67 | ], 68 | ] 69 | ``` 70 | 71 | Add these variables to your `.env` file: 72 | 73 | ``` 74 | GITHUB_REPO=username/repository 75 | GITHUB_TOKEN=your-github-personal-access-token 76 | ``` 77 | 78 | You can use the `github` log channel as your default `LOG_CHANNEL` or add it as part of your stack in `LOG_STACK`. 79 | 80 | ### Getting a GitHub Token 81 | 82 | To obtain a Personal Access Token: 83 | 84 | 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) 85 | 2. Review the pre-selected scopes (the `repo` scope should be checked) 86 | 3. Click "Generate token" 87 | 4. Copy the token immediately (you won't be able to access it again after leaving the page) 88 | 5. Add it to your `.env` file as `GITHUB_TOKEN` 89 | 90 | > **Note**: The token requires the `repo` scope to create issues in both public and private repositories. 91 | 92 | ## Usage 93 | 94 | Whenever an exception is thrown it will be logged as an issue to your repository. 95 | 96 | You can also use it like any other Laravel logging channel: 97 | 98 | ```php 99 | // Single channel 100 | Log::channel('github')->error('Something went wrong!'); 101 | 102 | // Or as part of a stack 103 | Log::stack(['daily', 'github'])->error('Something went wrong!'); 104 | ``` 105 | 106 | ## Advanced Configuration 107 | 108 | ### Customizing Templates 109 | 110 | The package uses Markdown templates to format issues and comments. You can customize these templates by publishing them: 111 | 112 | ```bash 113 | php artisan vendor:publish --tag="github-monolog-views" 114 | ``` 115 | 116 | This will copy the templates to `resources/views/vendor/github-monolog/` where you can modify them: 117 | 118 | - `issue.md`: Template for new issues 119 | - `comment.md`: Template for comments on existing issues 120 | - `previous_exception.md`: Template for previous exceptions in the chain 121 | 122 | > **Important**: The templates use HTML comments as section markers (e.g. `` and ``). These markers are used to intelligently remove empty sections from the rendered output. Please keep these markers intact when customizing the templates. 123 | 124 | Available template variables: 125 | - `{level}`: Log level (error, warning, etc.) 126 | - `{message}`: The error message or log content 127 | - `{simplified_stack_trace}`: A cleaned up stack trace 128 | - `{full_stack_trace}`: The complete stack trace 129 | - `{previous_exceptions}`: Details of any previous exceptions 130 | - `{context}`: Additional context data 131 | - `{extra}`: Extra log data 132 | - `{signature}`: Internal signature used for deduplication 133 | 134 | ### Deduplication 135 | 136 | Group similar errors to avoid duplicate issues. The package uses Laravel's cache system for deduplication storage. 137 | 138 | ```php 139 | 'github' => [ 140 | // ... basic config from above ... 141 | 'deduplication' => [ 142 | 'time' => 60, // Time window in seconds - how long to wait before creating a new issue 143 | 'store' => null, // Uses your default cache store (from cache.default) 144 | 'prefix' => 'dedup', // Prefix for cache keys 145 | ], 146 | ] 147 | ``` 148 | 149 | For cache store configuration, refer to the [Laravel Cache documentation](https://laravel.com/docs/cache). 150 | 151 | ### Buffering 152 | 153 | Buffer logs to reduce GitHub API calls. Customize the buffer size and overflow behavior to optimize performance: 154 | 155 | ```php 156 | 'github' => [ 157 | // ... basic config from above ... 158 | 'buffer' => [ 159 | 'limit' => 0, // Maximum records in buffer (0 = unlimited, flush on shutdown) 160 | 'flush_on_overflow' => true, // When limit is reached: true = flush all, false = remove oldest 161 | ], 162 | ] 163 | ``` 164 | 165 | When buffering is active: 166 | - Logs are collected in memory until flushed 167 | - Buffer is automatically flushed on application shutdown 168 | - When limit is reached: 169 | - With `flush_on_overflow = true`: All records are flushed 170 | - With `flush_on_overflow = false`: Only the oldest record is removed 171 | 172 | ### Tracing 173 | 174 | The package includes optional tracing capabilities that allow you to track requests and user data in your logs. Enable this feature through your configuration: 175 | 176 | ```php 177 | 'tracing' => [ 178 | 'enabled' => true, // Master switch for all tracing 179 | 'requests' => true, // Enable request tracing 180 | 'user' => true, // Enable user tracing 181 | ] 182 | ``` 183 | 184 | #### Request Tracing 185 | When request tracing is enabled, the following data is automatically logged: 186 | - URL 187 | - HTTP Method 188 | - Route information 189 | - Headers (filtered to remove sensitive data) 190 | - Request body 191 | 192 | #### User Tracing 193 | By default, user tracing only logs the user identifier to comply with GDPR regulations. However, you can customize the user data being logged by setting your own resolver: 194 | 195 | ```php 196 | use Naoray\LaravelGithubMonolog\Tracing\UserDataCollector; 197 | 198 | UserDataCollector::setUserDataResolver(function ($user) { 199 | return [ 200 | 'username' => $user->username, 201 | // Add any other user fields you want to log 202 | ]; 203 | }); 204 | ``` 205 | 206 | > **Note:** When customizing user data collection, ensure you comply with relevant privacy regulations and only collect necessary information. 207 | 208 | ### Signature Generator 209 | 210 | Control how errors are grouped by customizing the signature generator. By default, the package uses a generator that creates signatures based on exception details or log message content. 211 | 212 | ```php 213 | 'github' => [ 214 | // ... basic config from above ... 215 | 'signature_generator' => \Naoray\LaravelGithubMonolog\Deduplication\DefaultSignatureGenerator::class, 216 | ] 217 | ``` 218 | 219 | You can implement your own signature generator by implementing the `SignatureGeneratorInterface`: 220 | 221 | ```php 222 | use Monolog\LogRecord; 223 | use Naoray\LaravelGithubMonolog\Deduplication\SignatureGeneratorInterface; 224 | 225 | class CustomSignatureGenerator implements SignatureGeneratorInterface 226 | { 227 | public function generate(LogRecord $record): string 228 | { 229 | // Your custom logic to generate a signature 230 | return md5($record->message); 231 | } 232 | } 233 | ``` 234 | 235 | ## Testing 236 | 237 | ```bash 238 | composer test 239 | ``` 240 | 241 | ## Changelog 242 | 243 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 244 | 245 | ## Contributing 246 | 247 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 248 | 249 | ## Security Vulnerabilities 250 | 251 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 252 | 253 | ## Credits 254 | 255 | - [Krishan Koenig](https://github.com/Naoray) 256 | - [All Contributors](../../contributors) 257 | 258 | ## License 259 | 260 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 261 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | ## Upgrading from 2.x to 3.0 4 | 5 | ### Breaking Changes 6 | 7 | Version 3.0 introduces several breaking changes in how deduplication storage is handled: 8 | 9 | 1. **Removed Custom Store Implementations** 10 | - FileStore, RedisStore, and DatabaseStore have been removed 11 | - All deduplication storage now uses Laravel's cache system 12 | 13 | 2. **Configuration Changes** 14 | - Store-specific configuration options have been removed 15 | - New simplified cache-based configuration 16 | 17 | ### Migration Steps 18 | 19 | 1. **Update Package** 20 | ```bash 21 | composer require naoray/laravel-github-monolog:^3.0 22 | ``` 23 | 24 | 2. **Run Cleanup** 25 | - Keep your old configuration in place 26 | - Run the cleanup code in [Cleanup Code](#cleanup-code) to remove old storage artifacts 27 | - The cleanup code needs your old configuration to know what to clean up 28 | 29 | 3. **Update Configuration** 30 | - Migrate to new store-specific configuration 31 | - Add new cache-based configuration 32 | - Configure Laravel cache as needed 33 | 34 | ### Configuration Updates 35 | 36 | #### Before (2.x) 37 | ```php 38 | 'deduplication' => [ 39 | 'store' => 'redis', // or 'file', 'database' 40 | 'connection' => 'default', // Redis/Database connection 41 | 'prefix' => 'github-monolog:', // Redis prefix 42 | 'table' => 'github_monolog_deduplication', // Database table 43 | 'time' => 60, 44 | ], 45 | ``` 46 | 47 | #### After (3.0) 48 | ```php 49 | 'deduplication' => [ 50 | 'store' => null, // (optional) Uses Laravel's default cache store 51 | 'time' => 60, // Time window in seconds 52 | 'prefix' => 'dedup', // Cache key prefix 53 | ], 54 | ``` 55 | 56 | ### Cleanup Code 57 | 58 | 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: 59 | 60 | ```php 61 | use Illuminate\Support\Facades\{Schema, Redis, File, DB}; 62 | 63 | // Get your current config 64 | $config = config('logging.channels.github.deduplication', []); 65 | $store = $config['store'] ?? 'file'; 66 | 67 | if ($store === 'database') { 68 | // Clean up database table using your configured connection and table name 69 | $connection = $config['connection'] ?? config('database.default'); 70 | $table = $config['table'] ?? 'github_monolog_deduplication'; 71 | 72 | Schema::connection($connection)->dropIfExists($table); 73 | } 74 | 75 | if ($store === 'redis') { 76 | // Clean up Redis entries using your configured connection and prefix 77 | $connection = $config['connection'] ?? 'default'; 78 | $prefix = $config['prefix'] ?? 'github-monolog:'; 79 | Redis::connection($connection)->del($prefix . 'dedup'); 80 | } 81 | 82 | if ($store === 'file') { 83 | // Clean up file storage using your configured path 84 | $path = $config['path'] ?? storage_path('logs/github-monolog-deduplication.log'); 85 | if (File::exists($path)) { 86 | File::delete($path); 87 | } 88 | } 89 | ``` 90 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "naoray/laravel-github-monolog", 3 | "description": "Log driver to store logs as github issues", 4 | "keywords": [ 5 | "Krishan Koenig", 6 | "laravel", 7 | "monolog", 8 | "github", 9 | "logging" 10 | ], 11 | "homepage": "https://github.com/naoray/laravel-github-monolog", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Krishan Koenig", 16 | "email": "krishan.koenig@googlemail.com", 17 | "role": "Developer" 18 | } 19 | ], 20 | "require": { 21 | "php": "^8.3", 22 | "illuminate/cache": "^11.0||^12.0", 23 | "illuminate/contracts": "^11.0||^12.0", 24 | "illuminate/filesystem": "^11.0||^12.0", 25 | "illuminate/http": "^11.0||^12.0", 26 | "illuminate/support": "^11.37||^12.0", 27 | "monolog/monolog": "^3.6" 28 | }, 29 | "require-dev": { 30 | "laravel/pint": "^1.14", 31 | "nunomaduro/collision": "^8.1.1", 32 | "larastan/larastan": "^3.1", 33 | "orchestra/testbench": "^10.0||^9.0.0", 34 | "pestphp/pest": "^3.0", 35 | "pestphp/pest-plugin-arch": "^3.0", 36 | "pestphp/pest-plugin-laravel": "^3.0", 37 | "phpstan/extension-installer": "^1.3", 38 | "phpstan/phpstan-deprecation-rules": "^2.0", 39 | "phpstan/phpstan-phpunit": "^2.0" 40 | }, 41 | "autoload": { 42 | "psr-4": { 43 | "Naoray\\LaravelGithubMonolog\\": "src" 44 | } 45 | }, 46 | "autoload-dev": { 47 | "psr-4": { 48 | "Naoray\\LaravelGithubMonolog\\Tests\\": "tests" 49 | } 50 | }, 51 | "scripts": { 52 | "post-autoload-dump": "@composer run prepare", 53 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 54 | "analyse": "vendor/bin/phpstan analyse", 55 | "test": "vendor/bin/pest", 56 | "test-coverage": "vendor/bin/pest --coverage", 57 | "format": "vendor/bin/pint" 58 | }, 59 | "config": { 60 | "sort-packages": true, 61 | "allow-plugins": { 62 | "pestphp/pest-plugin": true, 63 | "phpstan/extension-installer": true 64 | } 65 | }, 66 | "extra": { 67 | "laravel": { 68 | "providers": [ 69 | "Naoray\\LaravelGithubMonolog\\GithubMonologServiceProvider" 70 | ] 71 | } 72 | }, 73 | "minimum-stability": "dev", 74 | "prefer-stable": true 75 | } 76 | -------------------------------------------------------------------------------- /resources/views/comment.md: -------------------------------------------------------------------------------- 1 | **Type:** {level} 2 | **Message:** {message} 3 | 4 | 5 | --- 6 | 7 | ## Stack Trace 8 | ```shell 9 | {simplified_stack_trace} 10 | ``` 11 | 12 |
13 | 📋 View Complete Stack Trace 14 | 15 | ```shell 16 | {full_stack_trace} 17 | ``` 18 |
19 | 20 | 21 |
22 | 🔍 View Previous Exceptions 23 | 24 | ```shell 25 | {previous_exceptions} 26 | ``` 27 |
28 | 29 | 30 | 31 | 32 | --- 33 | 34 | ## Context 35 | ```json 36 | {context} 37 | ``` 38 | 39 | 40 | 41 | --- 42 | 43 | ## Extra Data 44 | ```json 45 | {extra} 46 | ``` 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /resources/views/issue.md: -------------------------------------------------------------------------------- 1 | **Log Level:** {level} 2 | **Class:** {class} 3 | **Message:** {message} 4 | 5 | 6 | --- 7 | 8 | ## Stack Trace 9 | ```shell 10 | {simplified_stack_trace} 11 | ``` 12 | 13 |
14 | 📋 View Complete Stack Trace 15 | 16 | ```shell 17 | {full_stack_trace} 18 | ``` 19 |
20 | 21 | 22 |
23 | 🔍 View Previous Exceptions 24 | 25 | {previous_exceptions} 26 | 27 |
28 | 29 | 30 | 31 | 32 | --- 33 | 34 | ## Context 35 | ```json 36 | {context} 37 | ``` 38 | 39 | 40 | 41 | --- 42 | 43 | ## Extra Data 44 | ```json 45 | {extra} 46 | ``` 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /resources/views/previous_exception.md: -------------------------------------------------------------------------------- 1 | ## Previous Exception #{count} 2 | {message} 3 | 4 | 5 | ```shell 6 | {simplified_stack_trace} 7 | ``` 8 | 9 | --- 10 | 11 | ```shell 12 | {full_stack_trace} 13 | ``` 14 | 15 | -------------------------------------------------------------------------------- /src/Deduplication/CacheManager.php: -------------------------------------------------------------------------------- 1 | store = $store ?? config('cache.default'); 25 | $this->cache = Cache::store($this->store); 26 | } 27 | 28 | public function has(string $signature): bool 29 | { 30 | return $this->cache->has($this->composeKey($signature)); 31 | } 32 | 33 | public function add(string $signature): void 34 | { 35 | $this->cache->put( 36 | $this->composeKey($signature), 37 | Carbon::now()->timestamp, 38 | $this->ttl 39 | ); 40 | } 41 | 42 | private function composeKey(string $signature): string 43 | { 44 | return implode(self::KEY_SEPARATOR, [ 45 | self::KEY_PREFIX, 46 | $this->prefix, 47 | $signature, 48 | ]); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Deduplication/DeduplicationHandler.php: -------------------------------------------------------------------------------- 1 | cache = new CacheManager($store, $prefix, $ttl); 35 | } 36 | 37 | public function flush(): void 38 | { 39 | if ($this->bufferSize === 0) { 40 | return; 41 | } 42 | 43 | collect($this->buffer) 44 | ->map(function (LogRecord $record) { 45 | $signature = $this->signatureGenerator->generate($record); 46 | 47 | // Create new record with signature in extra data 48 | $record = $record->with(extra: ['github_issue_signature' => $signature] + $record->extra); 49 | 50 | // If the record is a duplicate, we don't want to process it 51 | if ($this->cache->has($signature)) { 52 | return null; 53 | } 54 | 55 | $this->cache->add($signature); 56 | 57 | return $record; 58 | }) 59 | ->filter() 60 | ->pipe(fn (Collection $records) => $this->handler->handleBatch($records->toArray())); 61 | 62 | $this->clear(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Deduplication/DefaultSignatureGenerator.php: -------------------------------------------------------------------------------- 1 | context['exception'] ?? null; 16 | 17 | if (! $exception instanceof Throwable) { 18 | return $this->generateFromMessage($record); 19 | } 20 | 21 | return $this->generateFromException($exception); 22 | } 23 | 24 | /** 25 | * Generate a signature from a message and context 26 | */ 27 | private function generateFromMessage(LogRecord $record): string 28 | { 29 | return md5($record->message.json_encode($record->context)); 30 | } 31 | 32 | /** 33 | * Generate a signature from an exception 34 | */ 35 | private function generateFromException(Throwable $exception): string 36 | { 37 | $trace = $exception->getTrace(); 38 | $firstFrame = ! empty($trace) ? $trace[0] : null; 39 | 40 | return md5(implode(':', [ 41 | $exception::class, 42 | $exception->getFile(), 43 | $exception->getLine(), 44 | $firstFrame ? ($firstFrame['file'] ?? '').':'.($firstFrame['line'] ?? '') : '', 45 | ])); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Deduplication/SignatureGeneratorInterface.php: -------------------------------------------------------------------------------- 1 | validateConfig($config); 24 | 25 | $handler = $this->createBaseHandler($config); 26 | $deduplicationHandler = $this->wrapWithDeduplication($handler, $config); 27 | 28 | return new Logger('github', [$deduplicationHandler]); 29 | } 30 | 31 | protected function validateConfig(array $config): void 32 | { 33 | if (! Arr::has($config, 'repo')) { 34 | throw new InvalidArgumentException('GitHub repository is required'); 35 | } 36 | 37 | if (! Arr::has($config, 'token')) { 38 | throw new InvalidArgumentException('GitHub token is required'); 39 | } 40 | } 41 | 42 | protected function createBaseHandler(array $config): Handler 43 | { 44 | $handler = new Handler( 45 | repo: $config['repo'], 46 | token: $config['token'], 47 | labels: Arr::get($config, 'labels', []), 48 | level: Arr::get($config, 'level', Level::Error), 49 | bubble: Arr::get($config, 'bubble', true) 50 | ); 51 | 52 | $handler->setFormatter($this->formatter); 53 | 54 | return $handler; 55 | } 56 | 57 | protected function wrapWithDeduplication(Handler $handler, array $config): DeduplicationHandler 58 | { 59 | $signatureGeneratorClass = Arr::get($config, 'signature_generator', DefaultSignatureGenerator::class); 60 | 61 | if (! is_subclass_of($signatureGeneratorClass, SignatureGeneratorInterface::class)) { 62 | throw new InvalidArgumentException( 63 | sprintf('Signature generator class [%s] must implement %s', $signatureGeneratorClass, SignatureGeneratorInterface::class) 64 | ); 65 | } 66 | 67 | /** @var SignatureGeneratorInterface $signatureGenerator */ 68 | $signatureGenerator = new $signatureGeneratorClass; 69 | 70 | $deduplication = Arr::get($config, 'deduplication', []); 71 | 72 | return new DeduplicationHandler( 73 | handler: $handler, 74 | signatureGenerator: $signatureGenerator, 75 | store: Arr::get($deduplication, 'store', config('cache.default')), 76 | prefix: Arr::get($deduplication, 'prefix', 'github-monolog:'), 77 | ttl: $this->getDeduplicationTime($config), 78 | level: Arr::get($config, 'level', Level::Error), 79 | bufferLimit: Arr::get($config, 'buffer.limit', 0), 80 | flushOnOverflow: Arr::get($config, 'buffer.flush_on_overflow', true) 81 | ); 82 | } 83 | 84 | protected function getDeduplicationTime(array $config): int 85 | { 86 | $time = Arr::get($config, 'deduplication.time', 60); 87 | 88 | if (! is_numeric($time) || $time < 0) { 89 | throw new InvalidArgumentException('Deduplication time must be a positive integer'); 90 | } 91 | 92 | return (int) $time; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/GithubMonologServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 20 | $this->publishes([ 21 | __DIR__.'/../resources/views' => resource_path('views/vendor/github-monolog'), 22 | ], 'github-monolog-views'); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Issues/Formatter.php: -------------------------------------------------------------------------------- 1 | extra['github_issue_signature'])) { 18 | throw new \RuntimeException('Record is missing github_issue_signature in extra data. Make sure the DeduplicationHandler is configured correctly.'); 19 | } 20 | 21 | return new Formatted( 22 | title: $this->templateRenderer->renderTitle($record), 23 | body: $this->templateRenderer->render($this->templateRenderer->getIssueStub(), $record, $record->extra['github_issue_signature']), 24 | comment: $this->templateRenderer->render($this->templateRenderer->getCommentStub(), $record, null), 25 | ); 26 | } 27 | 28 | public function formatBatch(array $records): array 29 | { 30 | return array_map([$this, 'format'], $records); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Issues/Formatters/ExceptionFormatter.php: -------------------------------------------------------------------------------- 1 | context['exception'] ?? null; 22 | 23 | // Handle case where the exception is stored as a string instead of a Throwable object 24 | if (is_string($exceptionData) && 25 | (str_contains($exceptionData, 'Stack trace:') || preg_match('/#\d+ \//', $exceptionData))) { 26 | 27 | return $this->formatExceptionString($exceptionData); 28 | } 29 | 30 | // Original code for Throwable objects 31 | if (! $exceptionData instanceof Throwable) { 32 | return []; 33 | } 34 | 35 | $message = $this->formatMessage($exceptionData->getMessage()); 36 | $stackTrace = $exceptionData->getTraceAsString(); 37 | 38 | $header = $this->formatHeader($exceptionData); 39 | 40 | return [ 41 | 'message' => $message, 42 | 'simplified_stack_trace' => $header."\n[stacktrace]\n".$this->stackTraceFormatter->format($stackTrace, true), 43 | 'full_stack_trace' => $header."\n[stacktrace]\n".$this->stackTraceFormatter->format($stackTrace, false), 44 | ]; 45 | } 46 | 47 | public function formatBatch(array $records): array 48 | { 49 | return array_map([$this, 'format'], $records); 50 | } 51 | 52 | public function formatTitle(Throwable $exception, string $level): string 53 | { 54 | $exceptionClass = (new ReflectionClass($exception))->getShortName(); 55 | $file = Str::replace(base_path(), '', $exception->getFile()); 56 | 57 | return Str::of('[{level}] {class} in {file}:{line} - {message}') 58 | ->replace('{level}', $level) 59 | ->replace('{class}', $exceptionClass) 60 | ->replace('{file}', $file) 61 | ->replace('{line}', (string) $exception->getLine()) 62 | ->replace('{message}', Str::limit($exception->getMessage(), self::TITLE_MAX_LENGTH)) 63 | ->toString(); 64 | } 65 | 66 | private function formatMessage(string $message): string 67 | { 68 | if (! str_contains($message, 'Stack trace:')) { 69 | return $message; 70 | } 71 | 72 | return (string) preg_replace('/\s+in\s+\/[^\s]+\.php:\d+.*$/s', '', $message); 73 | } 74 | 75 | private function formatHeader(Throwable $exception): string 76 | { 77 | return sprintf( 78 | '[%s] %s: %s at %s:%d', 79 | now()->format('Y-m-d H:i:s'), 80 | (new ReflectionClass($exception))->getShortName(), 81 | $exception->getMessage(), 82 | str_replace(base_path(), '', $exception->getFile()), 83 | $exception->getLine() 84 | ); 85 | } 86 | 87 | /** 88 | * Format an exception stored as a string. 89 | */ 90 | private function formatExceptionString(string $exceptionString): array 91 | { 92 | $message = $exceptionString; 93 | $stackTrace = ''; 94 | 95 | // Try to extract the message and stack trace 96 | if (preg_match('/^(.*?)(?:Stack trace:|#\d+ \/)/', $exceptionString, $matches)) { 97 | $message = trim($matches[1]); 98 | 99 | // Remove file/line info if present 100 | if (preg_match('/^(.*) in \/[^\s]+(?:\.php)? on line \d+$/s', $message, $fileMatches)) { 101 | $message = trim($fileMatches[1]); 102 | } 103 | 104 | // Extract stack trace 105 | $traceStart = strpos($exceptionString, 'Stack trace:'); 106 | if ($traceStart === false) { 107 | // Find the first occurrence of a stack frame pattern 108 | if (preg_match('/#\d+ \//', $exceptionString, $matches, PREG_OFFSET_CAPTURE)) { 109 | $traceStart = $matches[0][1]; 110 | } 111 | } 112 | 113 | if ($traceStart !== false) { 114 | $stackTrace = substr($exceptionString, $traceStart); 115 | } 116 | } 117 | 118 | $header = sprintf( 119 | '[%s] Exception: %s at unknown:0', 120 | now()->format('Y-m-d H:i:s'), 121 | $message 122 | ); 123 | 124 | return [ 125 | 'message' => $this->formatMessage($message), 126 | 'simplified_stack_trace' => $header."\n[stacktrace]\n".$this->stackTraceFormatter->format($stackTrace, true), 127 | 'full_stack_trace' => $header."\n[stacktrace]\n".$this->stackTraceFormatter->format($stackTrace, false), 128 | ]; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Issues/Formatters/Formatted.php: -------------------------------------------------------------------------------- 1 | extra['github_issue_signature'])) { 18 | throw new \RuntimeException('Record is missing github_issue_signature in extra data. Make sure the DeduplicationHandler is configured correctly.'); 19 | } 20 | 21 | return new Formatted( 22 | title: $this->templateRenderer->renderTitle($record), 23 | body: $this->templateRenderer->render($this->templateRenderer->getIssueStub(), $record, $record->extra['github_issue_signature']), 24 | comment: $this->templateRenderer->render($this->templateRenderer->getCommentStub(), $record, null), 25 | ); 26 | } 27 | 28 | public function formatBatch(array $records): array 29 | { 30 | return array_map([$this, 'format'], $records); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Issues/Formatters/PreviousExceptionFormatter.php: -------------------------------------------------------------------------------- 1 | previousExceptionStub = $this->stubLoader->load('previous_exception'); 24 | } 25 | 26 | public function format(LogRecord $record): string 27 | { 28 | $exception = $this->getException($record); 29 | 30 | if (! $exception instanceof Throwable) { 31 | return ''; 32 | } 33 | 34 | if (! $previous = $exception->getPrevious()) { 35 | return ''; 36 | } 37 | 38 | $exceptions = collect() 39 | ->range(1, self::MAX_PREVIOUS_EXCEPTIONS) 40 | ->map(function ($count) use (&$previous, $record) { 41 | if (! $previous) { 42 | return null; 43 | } 44 | 45 | $current = $previous; 46 | $previous = $previous->getPrevious(); 47 | 48 | $details = $this->exceptionFormatter->format( 49 | $record->with( 50 | context: ['exception' => $current], 51 | extra: [] 52 | ) 53 | ); 54 | 55 | return Str::of($this->previousExceptionStub) 56 | ->replace( 57 | ['{count}', '{message}', '{simplified_stack_trace}', '{full_stack_trace}'], 58 | [$count, $current->getMessage(), $details['simplified_stack_trace'], str_replace(base_path(), '', $details['full_stack_trace'])] 59 | ) 60 | ->toString(); 61 | }) 62 | ->filter() 63 | ->join("\n\n"); 64 | 65 | if (empty($exceptions)) { 66 | return ''; 67 | } 68 | 69 | if ($previous) { 70 | $exceptions .= "\n\n> Note: Additional previous exceptions were truncated\n"; 71 | } 72 | 73 | return $exceptions; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Issues/Formatters/StackTraceFormatter.php: -------------------------------------------------------------------------------- 1 | filter(fn ($line) => ! empty(trim($line))) 16 | ->map(function ($line) use ($collapseVendorFrames) { 17 | if (trim($line) === '"}') { 18 | return ''; 19 | } 20 | 21 | if (str_contains($line, '{"exception":"[object] ')) { 22 | return $this->formatInitialException($line); 23 | } 24 | 25 | if (! Str::isMatch('/#[0-9]+ /', $line)) { 26 | return $line; 27 | } 28 | 29 | $line = str_replace(base_path(), '', $line); 30 | 31 | $line = $this->padStackTraceLine($line); 32 | 33 | if ($collapseVendorFrames && $this->isVendorFrame($line)) { 34 | return self::VENDOR_FRAME_PLACEHOLDER; 35 | } 36 | 37 | return $line; 38 | }) 39 | ->pipe(fn ($lines) => $collapseVendorFrames ? $this->collapseVendorFrames($lines) : $lines) 40 | ->join("\n"); 41 | } 42 | 43 | /** 44 | * Stack trace lines start with #\d. Here we pad the numbers 0-9 45 | * with a preceding zero to keep everything in line visually. 46 | */ 47 | public function padStackTraceLine(string $line): string 48 | { 49 | return (string) preg_replace('/^#(\d)(?!\d)/', '#0$1', $line); 50 | } 51 | 52 | private function formatInitialException(string $line): array 53 | { 54 | [$message, $exception] = explode('{"exception":"[object] ', $line); 55 | 56 | return [ 57 | $message, 58 | $exception, 59 | ]; 60 | } 61 | 62 | private function isVendorFrame($line): bool 63 | { 64 | return str_contains((string) $line, self::VENDOR_FRAME_PLACEHOLDER) 65 | || str_contains((string) $line, '/vendor/') && ! Str::isMatch("/BoundMethod\.php\([0-9]+\): App/", $line) 66 | || str_contains((string) $line, '/artisan') 67 | || str_ends_with($line, '{main}'); 68 | } 69 | 70 | private function collapseVendorFrames(Collection $lines): Collection 71 | { 72 | $hasVendorFrame = false; 73 | 74 | return $lines->filter(function ($line) use (&$hasVendorFrame) { 75 | $isVendorFrame = $this->isVendorFrame($line); 76 | 77 | if ($isVendorFrame) { 78 | // Skip the line if a vendor frame has already been added. 79 | if ($hasVendorFrame) { 80 | return false; 81 | } 82 | 83 | // Otherwise, mark that a vendor frame has been added. 84 | $hasVendorFrame = true; 85 | } else { 86 | // Reset the flag if the current line is not a vendor frame. 87 | $hasVendorFrame = false; 88 | } 89 | 90 | return true; 91 | }); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Issues/Handler.php: -------------------------------------------------------------------------------- 1 | repo = $repo; 36 | $this->token = $token; 37 | $this->labels = array_unique(array_merge([self::DEFAULT_LABEL], $labels)); 38 | $this->client = Http::withToken($this->token)->baseUrl('https://api.github.com'); 39 | } 40 | 41 | /** 42 | * Override write to log issues to GitHub 43 | */ 44 | protected function write(LogRecord $record): void 45 | { 46 | if (! $record->formatted instanceof Formatted) { 47 | throw new \RuntimeException('Record must be formatted with '.Formatted::class); 48 | } 49 | 50 | $formatted = $record->formatted; 51 | 52 | try { 53 | $existingIssue = $this->findExistingIssue($record); 54 | 55 | if ($existingIssue) { 56 | $this->commentOnIssue($existingIssue['number'], $formatted); 57 | 58 | return; 59 | } 60 | 61 | $this->createIssue($formatted); 62 | } catch (RequestException $e) { 63 | if ($e->response->serverError()) { 64 | throw $e; 65 | } 66 | 67 | $this->createFallbackIssue($formatted, $e->response->body()); 68 | } 69 | } 70 | 71 | /** 72 | * Find an existing issue with the given signature 73 | */ 74 | private function findExistingIssue(LogRecord $record): ?array 75 | { 76 | if (! isset($record->extra['github_issue_signature'])) { 77 | throw new \RuntimeException('Record is missing github_issue_signature in extra data. Make sure the DeduplicationHandler is configured correctly.'); 78 | } 79 | 80 | return $this->client 81 | ->get('/search/issues', [ 82 | 'q' => "repo:{$this->repo} is:issue is:open label:".self::DEFAULT_LABEL." \"Signature: {$record->extra['github_issue_signature']}\"", 83 | ]) 84 | ->throw() 85 | ->json('items.0', null); 86 | } 87 | 88 | /** 89 | * Add a comment to an existing issue 90 | */ 91 | private function commentOnIssue(int $issueNumber, Formatted $formatted): void 92 | { 93 | $this->client 94 | ->post("/repos/{$this->repo}/issues/{$issueNumber}/comments", [ 95 | 'body' => $formatted->comment, 96 | ]) 97 | ->throw(); 98 | } 99 | 100 | /** 101 | * Create a new GitHub issue 102 | */ 103 | private function createIssue(Formatted $formatted): void 104 | { 105 | $this->client 106 | ->post("/repos/{$this->repo}/issues", [ 107 | 'title' => $formatted->title, 108 | 'body' => $formatted->body, 109 | 'labels' => $this->labels, 110 | ]) 111 | ->throw(); 112 | } 113 | 114 | /** 115 | * Create a fallback issue when the main issue creation fails 116 | */ 117 | private function createFallbackIssue(Formatted $formatted, string $errorMessage): void 118 | { 119 | $this->client 120 | ->post("/repos/{$this->repo}/issues", [ 121 | 'title' => '[GitHub Monolog Error] '.$formatted->title, 122 | 'body' => "**Original Error Message:**\n{$formatted->body}\n\n**Integration Error:**\n{$errorMessage}", 123 | 'labels' => array_merge($this->labels, ['monolog-integration-error']), 124 | ]) 125 | ->throw(); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Issues/InteractsWithLogRecord.php: -------------------------------------------------------------------------------- 1 | context['exception']) 13 | && $record->context['exception'] instanceof Throwable; 14 | } 15 | 16 | protected function getException(LogRecord $record): ?Throwable 17 | { 18 | return $this->hasException($record) ? $record->context['exception'] : null; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Issues/SectionMapping.php: -------------------------------------------------------------------------------- 1 | 'stacktrace', 11 | '{full_stack_trace}' => 'stacktrace', 12 | '{previous_exceptions}' => 'prev-exception', 13 | '{context}' => 'context', 14 | '{extra}' => 'extra', 15 | '{prev_exception_simplified_stack_trace}' => 'prev-exception-stacktrace', 16 | '{prev_exception_full_stack_trace}' => 'prev-exception-stacktrace', 17 | ]; 18 | 19 | public static function getSectionsToRemove(array $replacements): array 20 | { 21 | return collect(self::SECTION_MAPPINGS) 22 | ->when(empty($replacements), fn (Collection $collection) => $collection->values()->unique()) 23 | ->when(! empty($replacements), function (Collection $collection) use ($replacements) { 24 | return $collection 25 | ->filter(fn (string $_, string $placeholder) => isset($replacements[$placeholder]) && empty($replacements[$placeholder])) 26 | ->values() 27 | ->unique(); 28 | }) 29 | ->values() 30 | ->toArray(); 31 | } 32 | 33 | public static function getRemainingSections(array $sectionsToRemove): array 34 | { 35 | return collect(self::SECTION_MAPPINGS) 36 | ->values() 37 | ->unique() 38 | ->diff($sectionsToRemove) 39 | ->values() 40 | ->toArray(); 41 | } 42 | 43 | public static function getSectionPattern(string $section, bool $removeContent = false): string 44 | { 45 | if ($removeContent) { 46 | return "/.*?\n?/s"; 47 | } 48 | 49 | return "/\s*(.*?)\s*/s"; 50 | } 51 | 52 | public static function getStandaloneFlagPattern(): string 53 | { 54 | return '/\n?/s'; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Issues/StubLoader.php: -------------------------------------------------------------------------------- 1 | issueStub = $this->stubLoader->load('issue'); 29 | $this->commentStub = $this->stubLoader->load('comment'); 30 | } 31 | 32 | public function render(string $template, LogRecord $record, ?string $signature = null): string 33 | { 34 | $replacements = $this->buildReplacements($record, $signature); 35 | 36 | return $this->sectionCleaner->clean($template, $replacements); 37 | } 38 | 39 | public function renderTitle(LogRecord $record): string 40 | { 41 | $exception = $this->getException($record); 42 | 43 | if (! $exception) { 44 | return Str::of('[{level}] {message}') 45 | ->replace('{level}', $record->level->getName()) 46 | ->replace('{message}', Str::limit($record->message, self::TITLE_MAX_LENGTH)) 47 | ->toString(); 48 | } 49 | 50 | return $this->exceptionFormatter->formatTitle($exception, $record->level->getName()); 51 | } 52 | 53 | public function getIssueStub(): string 54 | { 55 | return $this->issueStub; 56 | } 57 | 58 | public function getCommentStub(): string 59 | { 60 | return $this->commentStub; 61 | } 62 | 63 | private function buildReplacements(LogRecord $record, ?string $signature): array 64 | { 65 | $exception = $this->getException($record); 66 | $exceptionDetails = $exception instanceof Throwable ? $this->exceptionFormatter->format($record) : []; 67 | 68 | return [ 69 | // Core replacements (always present) 70 | '{level}' => $record->level->getName(), 71 | '{message}' => $record->message, 72 | '{class}' => $exception instanceof Throwable ? get_class($exception) : '', 73 | '{signature}' => $signature ?? '', 74 | 75 | // Section replacements (may be empty) 76 | '{simplified_stack_trace}' => $exceptionDetails['simplified_stack_trace'] ?? '', 77 | '{full_stack_trace}' => $exceptionDetails['full_stack_trace'] ?? '', 78 | '{previous_exceptions}' => $this->hasException($record) ? $this->previousExceptionFormatter->format($record) : '', 79 | '{context}' => $this->formatContext($record->context), 80 | '{extra}' => $this->formatExtra(Arr::except($record->extra, ['github_issue_signature'])), 81 | ]; 82 | } 83 | 84 | private function formatContext(array $context): string 85 | { 86 | $context = Arr::except($context, ['exception']); 87 | 88 | if (empty($context)) { 89 | return ''; 90 | } 91 | 92 | return json_encode($context, JSON_PRETTY_PRINT); 93 | } 94 | 95 | private function formatExtra(array $extra): string 96 | { 97 | if (empty($extra)) { 98 | return ''; 99 | } 100 | 101 | return json_encode($extra, JSON_PRETTY_PRINT); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Issues/TemplateSectionCleaner.php: -------------------------------------------------------------------------------- 1 | replace(array_keys($replacements), array_values($replacements)) 13 | ->toString(); 14 | 15 | // Remove empty sections 16 | $sectionsToRemove = SectionMapping::getSectionsToRemove($replacements); 17 | foreach ($sectionsToRemove as $section) { 18 | $pattern = SectionMapping::getSectionPattern($section, true); 19 | $content = (string) preg_replace($pattern, '', $content); 20 | } 21 | 22 | // Remove flags from non-empty sections 23 | $remainingSections = SectionMapping::getRemainingSections($sectionsToRemove); 24 | foreach ($remainingSections as $section) { 25 | $pattern = SectionMapping::getSectionPattern($section); 26 | $content = (string) preg_replace($pattern, '$1', $content); 27 | } 28 | 29 | // Remove any remaining standalone flags 30 | $content = (string) preg_replace(SectionMapping::getStandaloneFlagPattern(), '', $content); 31 | 32 | // Normalize multiple newlines between content and signature 33 | $content = (string) preg_replace('/\n{2,}