├── resources
└── views
│ ├── previous_exception.md
│ ├── comment.md
│ └── issue.md
├── src
├── Issues
│ ├── Formatters
│ │ ├── Formatted.php
│ │ ├── IssueFormatter.php
│ │ ├── PreviousExceptionFormatter.php
│ │ ├── StackTraceFormatter.php
│ │ └── ExceptionFormatter.php
│ ├── InteractsWithLogRecord.php
│ ├── StubLoader.php
│ ├── Formatter.php
│ ├── TemplateSectionCleaner.php
│ ├── SectionMapping.php
│ ├── TemplateRenderer.php
│ └── Handler.php
├── Deduplication
│ ├── SignatureGeneratorInterface.php
│ ├── CacheManager.php
│ ├── DefaultSignatureGenerator.php
│ └── DeduplicationHandler.php
├── Tracing
│ ├── EventHandler.php
│ ├── UserDataCollector.php
│ └── RequestDataCollector.php
├── GithubMonologServiceProvider.php
└── GithubIssueHandlerFactory.php
├── LICENSE.md
├── composer.json
├── UPGRADE.md
├── CHANGELOG.md
└── README.md
/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/Issues/Formatters/Formatted.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/Tracing/EventHandler.php:
--------------------------------------------------------------------------------
1 | listen(RouteMatched::class, RequestDataCollector::class);
17 | }
18 |
19 | if (isset($config['user']) && $config['user']) {
20 | $events->listen(Authenticated::class, UserDataCollector::class);
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Issues/StubLoader.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/Tracing/UserDataCollector.php:
--------------------------------------------------------------------------------
1 | getUserDataResolver()($event->user)
23 | );
24 | }
25 |
26 | public function getUserDataResolver(): ?callable
27 | {
28 | return self::$userDataResolver
29 | ?? fn (Authenticatable $user) => ['id' => $user->getAuthIdentifier()];
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/IssueFormatter.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/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/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/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,}.*?\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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/GithubIssueHandlerFactory.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/Issues/TemplateRenderer.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Laravel GitHub Issue Logger
2 |
3 | [](https://packagist.org/packages/naoray/laravel-github-monolog)
4 | [](https://github.com/naoray/laravel-github-monolog/actions?query=workflow%3Arun-tests+branch%3Amain)
5 | [](https://github.com/naoray/laravel-github-monolog/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain)
6 | [](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 |
32 |
33 | The issue appears in your repository with all the detailed information about the error:
34 |
35 |
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 |
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 |
--------------------------------------------------------------------------------