diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index d21c0c3..e7d9a52 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -31,7 +31,7 @@ jobs:
os: [ubuntu-latest]
php: [8.4, 8.3]
laravel: [11.*]
- stability: [prefer-lowest, prefer-stable]
+ stability: [prefer-stable]
include:
- laravel: 11.*
testbench: 9.*
diff --git a/README.md b/README.md
index fdeadd7..7ef8207 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ Automatically create GitHub issues from your Laravel exceptions & logs. Perfect
- 🏷️ Support customizable labels
- 🎯 Smart deduplication to prevent issue spam
- ⚡️ Buffered logging for better performance
+- 📝 Customizable issue templates
## Showcase
@@ -103,51 +104,46 @@ Log::stack(['daily', 'github'])->error('Something went wrong!');
## Advanced Configuration
-Deduplication and buffering are enabled by default to enhance logging. Customize these features to suit your needs.
+### Customizing Templates
+
+The package uses Markdown templates to format issues and comments. You can customize these templates by publishing them:
+
+```bash
+php artisan vendor:publish --tag="github-monolog-views"
+```
+
+This will copy the templates to `resources/views/vendor/github-monolog/` where you can modify them:
+
+- `issue.md`: Template for new issues
+- `comment.md`: Template for comments on existing issues
+- `previous_exception.md`: Template for previous exceptions in the chain
+
+Available template variables:
+- `{level}`: Log level (error, warning, etc.)
+- `{message}`: The error message or log content
+- `{simplified_stack_trace}`: A cleaned up stack trace
+- `{full_stack_trace}`: The complete stack trace
+- `{previous_exceptions}`: Details of any previous exceptions
+- `{context}`: Additional context data
+- `{extra}`: Extra log data
+- `{signature}`: Internal signature used for deduplication
### Deduplication
-Group similar errors to avoid duplicate issues. By default, the package uses file-based storage. Customize the storage and time window to fit your application.
+Group similar errors to avoid duplicate issues. The package uses Laravel's cache system for deduplication storage.
```php
'github' => [
// ... basic config from above ...
'deduplication' => [
- 'store' => 'file', // Default store
- 'time' => 60, // Time window in seconds
+ 'time' => 60, // Time window in seconds - how long to wait before creating a new issue
+ 'store' => null, // Uses your default cache store (from cache.default)
+ 'prefix' => 'dedup', // Prefix for cache keys
],
]
```
-#### Alternative Storage Options
-
-Consider other storage options in these Laravel-specific scenarios:
-
-- **Redis Store**: Use when:
- - Running async queue jobs (file storage won't work across processes)
- - Using Laravel Horizon for queue management
- - Running multiple application instances behind a load balancer
-
- ```php
- 'deduplication' => [
- 'store' => 'redis',
- 'prefix' => 'github-monolog:',
- 'connection' => 'default', // Uses your Laravel Redis connection
- ],
- ```
-
-- **Database Store**: Use when:
- - Running queue jobs but Redis isn't available
- - Need to persist deduplication data across deployments
- - Want to query/debug deduplication history via database
-
- ```php
- 'deduplication' => [
- 'store' => 'database',
- 'table' => 'github_monolog_deduplication',
- 'connection' => null, // Uses your default database connection
- ],
- ```
+For cache store configuration, refer to the [Laravel Cache documentation](https://laravel.com/docs/cache).
### Buffering
diff --git a/UPGRADE.md b/UPGRADE.md
new file mode 100644
index 0000000..2dfd682
--- /dev/null
+++ b/UPGRADE.md
@@ -0,0 +1,89 @@
+# Upgrade Guide
+
+## Upgrading from 2.x to 3.0
+
+### Breaking Changes
+
+Version 3.0 introduces several breaking changes in how deduplication storage is handled:
+
+1. **Removed Custom Store Implementations**
+ - FileStore, RedisStore, and DatabaseStore have been removed
+ - All deduplication storage now uses Laravel's cache system
+
+2. **Configuration Changes**
+ - Store-specific configuration options have been removed
+ - New simplified cache-based configuration
+
+### Migration Steps
+
+1. **Update Package**
+ ```bash
+ composer require naoray/laravel-github-monolog:^3.0
+ ```
+
+2. **Run Cleanup**
+ - Keep your old configuration in place
+ - Run the cleanup code in [Cleanup Code](#cleanup-code) to remove old storage artifacts
+ - The cleanup code needs your old configuration to know what to clean up
+
+3. **Update Configuration**
+ - Migrate to new store-specific configuration
+ - Add new cache-based configuration
+ - Configure Laravel cache as needed
+
+### Configuration Updates
+
+#### Before (2.x)
+```php
+'deduplication' => [
+ 'store' => 'redis', // or 'file', 'database'
+ 'connection' => 'default', // Redis/Database connection
+ 'prefix' => 'github-monolog:', // Redis prefix
+ 'table' => 'github_monolog_deduplication', // Database table
+ 'time' => 60,
+],
+```
+
+#### After (3.0)
+```php
+'deduplication' => [
+ 'store' => null, // (optional) Uses Laravel's default cache store
+ 'time' => 60, // Time window in seconds
+ 'prefix' => 'dedup', // Cache key prefix
+],
+```
+
+### Cleanup Code
+
+Before updating your configuration to the new format, you should clean up artifacts from the 2.x version. The cleanup code uses your existing configuration to find and remove old storage:
+
+```php
+use Illuminate\Support\Facades\{Schema, Redis, File, DB};
+
+// Get your current config
+$config = config('logging.channels.github.deduplication', []);
+$store = $config['store'] ?? 'file';
+
+if ($store === 'database') {
+ // Clean up database table using your configured connection and table name
+ $connection = $config['connection'] ?? config('database.default');
+ $table = $config['table'] ?? 'github_monolog_deduplication';
+
+ Schema::connection($connection)->dropIfExists($table);
+}
+
+if ($store === 'redis') {
+ // Clean up Redis entries using your configured connection and prefix
+ $connection = $config['connection'] ?? 'default';
+ $prefix = $config['prefix'] ?? 'github-monolog:';
+ Redis::connection($connection)->del($prefix . 'dedup');
+}
+
+if ($store === 'file') {
+ // Clean up file storage using your configured path
+ $path = $config['path'] ?? storage_path('logs/github-monolog-deduplication.log');
+ if (File::exists($path)) {
+ File::delete($path);
+ }
+}
+```
diff --git a/composer.json b/composer.json
index 4f1d0b2..5c89ef9 100644
--- a/composer.json
+++ b/composer.json
@@ -23,16 +23,17 @@
"illuminate/http": "^11.0",
"illuminate/support": "^11.0",
"illuminate/filesystem": "^11.0",
+ "illuminate/cache": "^11.0",
"monolog/monolog": "^3.6"
},
"require-dev": {
"laravel/pint": "^1.14",
- "nunomaduro/collision": "^8.1.1||^7.10.0",
+ "nunomaduro/collision": "^8.1.1",
"larastan/larastan": "^2.9",
- "orchestra/testbench": "^9.0.0||^8.22.0",
- "pestphp/pest": "^2.35|^3.0",
- "pestphp/pest-plugin-arch": "^2.7||^3.0",
- "pestphp/pest-plugin-laravel": "^2.4||^3.0",
+ "orchestra/testbench": "^9.0.0",
+ "pestphp/pest": "^3.0",
+ "pestphp/pest-plugin-arch": "^3.0",
+ "pestphp/pest-plugin-laravel": "^3.0",
"phpstan/extension-installer": "^1.3",
"phpstan/phpstan-deprecation-rules": "^1.1",
"phpstan/phpstan-phpunit": "^1.3"
@@ -62,6 +63,13 @@
"phpstan/extension-installer": true
}
},
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Naoray\\LaravelGithubMonolog\\GithubMonologServiceProvider"
+ ]
+ }
+ },
"minimum-stability": "dev",
"prefer-stable": true
}
diff --git a/resources/views/comment.md b/resources/views/comment.md
new file mode 100644
index 0000000..36986d3
--- /dev/null
+++ b/resources/views/comment.md
@@ -0,0 +1,28 @@
+# New Occurrence
+
+**Log Level:** {level}
+
+{message}
+
+**Simplified Stack Trace:**
+```php
+{simplified_stack_trace}
+```
+
+
+Complete Stack Trace
+
+```php
+{full_stack_trace}
+```
+
+
+
+Previous Exceptions
+
+{previous_exceptions}
+
+
+{context}
+
+{extra}
diff --git a/resources/views/issue.md b/resources/views/issue.md
new file mode 100644
index 0000000..fcd6903
--- /dev/null
+++ b/resources/views/issue.md
@@ -0,0 +1,28 @@
+**Log Level:** {level}
+
+{message}
+
+**Simplified Stack Trace:**
+```php
+{simplified_stack_trace}
+```
+
+
+Complete Stack Trace
+
+```php
+{full_stack_trace}
+```
+
+
+
+Previous Exceptions
+
+{previous_exceptions}
+
+
+{context}
+
+{extra}
+
+
diff --git a/resources/views/previous_exception.md b/resources/views/previous_exception.md
new file mode 100644
index 0000000..cebdf93
--- /dev/null
+++ b/resources/views/previous_exception.md
@@ -0,0 +1,15 @@
+### Previous Exception #{count}
+**Type:** {type}
+
+**Simplified Stack Trace:**
+```php
+{simplified_stack_trace}
+```
+
+
+Complete Stack Trace
+
+```php
+{full_stack_trace}
+```
+
diff --git a/src/Deduplication/CacheManager.php b/src/Deduplication/CacheManager.php
new file mode 100644
index 0000000..e4d04b8
--- /dev/null
+++ b/src/Deduplication/CacheManager.php
@@ -0,0 +1,68 @@
+store = $store ?? config('cache.default');
+ $this->cache = Cache::store($this->store);
+ }
+
+ public function has(string $signature): bool
+ {
+ return $this->cache->has($this->composeKey($signature));
+ }
+
+ public function add(string $signature): void
+ {
+ $this->cache->put(
+ $this->composeKey($signature),
+ Carbon::now()->timestamp,
+ $this->ttl
+ );
+ }
+
+ /**
+ * Clear all entries for the current prefix.
+ * Note: This is a best-effort operation and might not work with all cache stores.
+ */
+ public function clear(): void
+ {
+ // For Redis/Memcached stores that support tag-like operations
+ if (method_exists($this->cache->getStore(), 'flush')) {
+ $this->cache->getStore()->flush();
+
+ return;
+ }
+
+ // For other stores, we'll have to rely on TTL cleanup
+ // You might want to implement a more specific cleanup strategy
+ // based on your cache store if needed
+ }
+
+ private function composeKey(string $signature): string
+ {
+ return implode(self::KEY_SEPARATOR, [
+ self::KEY_PREFIX,
+ $this->prefix,
+ $signature,
+ ]);
+ }
+}
diff --git a/src/Deduplication/DeduplicationHandler.php b/src/Deduplication/DeduplicationHandler.php
index 8e4aa94..5e5305c 100644
--- a/src/Deduplication/DeduplicationHandler.php
+++ b/src/Deduplication/DeduplicationHandler.php
@@ -7,14 +7,17 @@
use Monolog\Handler\HandlerInterface;
use Monolog\Level;
use Monolog\LogRecord;
-use Naoray\LaravelGithubMonolog\Deduplication\Stores\StoreInterface;
class DeduplicationHandler extends BufferHandler
{
+ private CacheManager $cache;
+
public function __construct(
HandlerInterface $handler,
- protected StoreInterface $store,
protected SignatureGeneratorInterface $signatureGenerator,
+ string $store = 'default',
+ string $prefix = 'github-monolog:dedup:',
+ int $ttl = 60,
int|string|Level $level = Level::Error,
int $bufferLimit = 0,
bool $bubble = true,
@@ -27,6 +30,8 @@ public function __construct(
bubble: $bubble,
flushOnOverflow: $flushOnOverflow,
);
+
+ $this->cache = new CacheManager($store, $prefix, $ttl);
}
public function flush(): void
@@ -42,12 +47,12 @@ public function flush(): void
// Create new record with signature in extra data
$record = $record->with(extra: ['github_issue_signature' => $signature] + $record->extra);
- // If the record is a duplicate, we don't want to add it to the store
- if ($this->store->isDuplicate($record, $signature)) {
+ // If the record is a duplicate, we don't want to process it
+ if ($this->cache->has($signature)) {
return null;
}
- $this->store->add($record, $signature);
+ $this->cache->add($signature);
return $record;
})
diff --git a/src/Deduplication/Stores/AbstractStore.php b/src/Deduplication/Stores/AbstractStore.php
deleted file mode 100644
index c8805bc..0000000
--- a/src/Deduplication/Stores/AbstractStore.php
+++ /dev/null
@@ -1,53 +0,0 @@
-get() as $entry) {
- [$timestamp, $storedSignature] = explode(':', $entry, 2);
- $timestamp = (int) $timestamp;
-
- if ($this->isExpired($timestamp)) {
- continue;
- }
-
- if ($storedSignature === $signature) {
- $foundDuplicate = true;
- }
- }
-
- return $foundDuplicate;
- }
-
- protected function isExpired(int $timestamp): bool
- {
- return $this->getTimestampValidity() > $timestamp;
- }
-
- protected function getTimestampValidity(): int
- {
- return $this->getTimestamp() - $this->time;
- }
-
- protected function getTimestamp(): int
- {
- return Carbon::now()->timestamp;
- }
-}
diff --git a/src/Deduplication/Stores/DatabaseStore.php b/src/Deduplication/Stores/DatabaseStore.php
deleted file mode 100644
index a84882c..0000000
--- a/src/Deduplication/Stores/DatabaseStore.php
+++ /dev/null
@@ -1,69 +0,0 @@
-connection = $connection === 'default' ? config('database.default') : $connection;
- $this->table = $table;
-
- $this->ensureTableExists();
- }
-
- public function get(): array
- {
- return DB::connection($this->connection)
- ->table($this->table)
- ->where('created_at', '>=', $this->getTimestampValidity())
- ->get()
- ->map(fn ($row) => $this->buildEntry($row->signature, $row->created_at))
- ->all();
- }
-
- public function add(LogRecord $record, string $signature): void
- {
- DB::connection($this->connection)
- ->table($this->table)
- ->insert([
- 'signature' => $signature,
- 'created_at' => $this->getTimestamp(),
- ]);
- }
-
- public function cleanup(): void
- {
- DB::connection($this->connection)
- ->table($this->table)
- ->where('created_at', '<', $this->getTimestampValidity())
- ->delete();
- }
-
- public function ensureTableExists(): void
- {
- if (! Schema::connection($this->connection)->hasTable($this->table)) {
- Schema::connection($this->connection)->create($this->table, function (Blueprint $table) {
- $table->id();
- $table->string('signature');
- $table->integer('created_at')->index();
-
- $table->index(['signature', 'created_at']);
- });
- }
- }
-}
diff --git a/src/Deduplication/Stores/FileStore.php b/src/Deduplication/Stores/FileStore.php
deleted file mode 100644
index aea6587..0000000
--- a/src/Deduplication/Stores/FileStore.php
+++ /dev/null
@@ -1,61 +0,0 @@
-path));
- }
-
- public function get(): array
- {
- if ($this->fileIsMissing()) {
- return [];
- }
-
- return Str::of(File::get($this->path))
- ->explode(PHP_EOL)
- ->filter(fn ($entry) => $entry && str_contains($entry, ':') && is_numeric(explode(':', $entry, 2)[0]))
- ->toArray();
- }
-
- public function add(LogRecord $record, string $signature): void
- {
- $entry = $this->buildEntry($signature, $this->getTimestamp());
- $content = File::exists($this->path) ? File::get($this->path) : '';
-
- File::put(
- $this->path,
- ($content ? $content.PHP_EOL : '').$entry
- );
- }
-
- public function cleanup(): void
- {
- $valid = collect($this->get())
- ->filter(function ($entry) {
- [$timestamp] = explode(':', $entry, 2);
-
- return is_numeric($timestamp) && ! $this->isExpired((int) $timestamp);
- })
- ->join(PHP_EOL);
-
- // overwrite the file with the new content
- File::put($this->path, $valid);
- }
-
- protected function fileIsMissing(): bool
- {
- return File::missing($this->path);
- }
-}
diff --git a/src/Deduplication/Stores/RedisStore.php b/src/Deduplication/Stores/RedisStore.php
deleted file mode 100644
index 4ef67e1..0000000
--- a/src/Deduplication/Stores/RedisStore.php
+++ /dev/null
@@ -1,63 +0,0 @@
-connection = $connection;
- $this->prefix = $prefix;
- }
-
- private function redis()
- {
- return Redis::connection($this->connection === 'default' ? null : $this->connection);
- }
-
- // Key Management
- public function getKey(): string
- {
- return $this->prefix.'dedup';
- }
-
- // Storage Operations
- public function add(LogRecord $record, string $signature): void
- {
- $this->redis()->zadd($this->getKey(), [
- $signature => $this->getTimestamp(),
- ]);
- }
-
- public function get(): array
- {
- $entries = $this->redis()->zrangebyscore(
- $this->getKey(),
- $this->getTimestampValidity(),
- '+inf',
- ['withscores' => true]
- );
-
- return array_map(
- fn ($entry, $score) => $this->buildEntry($entry, (int) $score),
- array_keys($entries),
- array_values($entries)
- );
- }
-
- public function cleanup(): void
- {
- $this->redis()->zremrangebyscore($this->getKey(), '-inf', $this->getTimestampValidity());
- }
-}
diff --git a/src/Deduplication/Stores/StoreInterface.php b/src/Deduplication/Stores/StoreInterface.php
deleted file mode 100644
index cba2edb..0000000
--- a/src/Deduplication/Stores/StoreInterface.php
+++ /dev/null
@@ -1,30 +0,0 @@
-
- */
- public function get(): array;
-
- /**
- * Add a new deduplication entry
- */
- public function add(LogRecord $record, string $signature): void;
-
- /**
- * Check if a record with the given signature is a duplicate
- */
- public function isDuplicate(LogRecord $record, string $signature): bool;
-
- /**
- * Clean up expired entries
- */
- public function cleanup(): void;
-}
diff --git a/src/GithubIssueHandlerFactory.php b/src/GithubIssueHandlerFactory.php
index 6931093..2ac29f8 100644
--- a/src/GithubIssueHandlerFactory.php
+++ b/src/GithubIssueHandlerFactory.php
@@ -9,15 +9,15 @@
use Naoray\LaravelGithubMonolog\Deduplication\DeduplicationHandler;
use Naoray\LaravelGithubMonolog\Deduplication\DefaultSignatureGenerator;
use Naoray\LaravelGithubMonolog\Deduplication\SignatureGeneratorInterface;
-use Naoray\LaravelGithubMonolog\Deduplication\Stores\DatabaseStore;
-use Naoray\LaravelGithubMonolog\Deduplication\Stores\FileStore;
-use Naoray\LaravelGithubMonolog\Deduplication\Stores\RedisStore;
-use Naoray\LaravelGithubMonolog\Deduplication\Stores\StoreInterface;
-use Naoray\LaravelGithubMonolog\Issues\Formatter;
+use Naoray\LaravelGithubMonolog\Issues\Formatters\IssueFormatter;
use Naoray\LaravelGithubMonolog\Issues\Handler;
class GithubIssueHandlerFactory
{
+ public function __construct(
+ private readonly IssueFormatter $formatter,
+ ) {}
+
public function __invoke(array $config): Logger
{
$this->validateConfig($config);
@@ -49,7 +49,7 @@ protected function createBaseHandler(array $config): Handler
bubble: Arr::get($config, 'bubble', true)
);
- $handler->setFormatter(new Formatter);
+ $handler->setFormatter($this->formatter);
return $handler;
}
@@ -67,34 +67,20 @@ protected function wrapWithDeduplication(Handler $handler, array $config): Dedup
/** @var SignatureGeneratorInterface $signatureGenerator */
$signatureGenerator = new $signatureGeneratorClass;
+ $deduplication = Arr::get($config, 'deduplication', []);
+
return new DeduplicationHandler(
handler: $handler,
- store: $this->createStore($config),
signatureGenerator: $signatureGenerator,
+ store: Arr::get($deduplication, 'store', config('cache.default')),
+ prefix: Arr::get($deduplication, 'prefix', 'github-monolog:'),
+ ttl: $this->getDeduplicationTime($config),
level: Arr::get($config, 'level', Level::Error),
- bubble: true,
bufferLimit: Arr::get($config, 'buffer.limit', 0),
flushOnOverflow: Arr::get($config, 'buffer.flush_on_overflow', true)
);
}
- protected function createStore(array $config): StoreInterface
- {
- $deduplication = Arr::get($config, 'deduplication', []);
- $driver = Arr::get($deduplication, 'store', 'file');
- $time = $this->getDeduplicationTime($config);
- $prefix = Arr::get($deduplication, 'prefix', 'github-monolog:');
- $connection = Arr::get($deduplication, 'connection', 'default');
- $table = Arr::get($deduplication, 'table', 'github_monolog_deduplication');
- $path = Arr::get($deduplication, 'path', storage_path('logs/github-monolog-deduplication.log'));
-
- return match ($driver) {
- 'redis' => new RedisStore(prefix: $prefix, time: $time, connection: $connection),
- 'database' => new DatabaseStore(time: $time, table: $table, connection: $connection),
- default => new FileStore(path: $path, time: $time),
- };
- }
-
protected function getDeduplicationTime(array $config): int
{
$time = Arr::get($config, 'deduplication.time', 60);
diff --git a/src/GithubMonologServiceProvider.php b/src/GithubMonologServiceProvider.php
new file mode 100644
index 0000000..b174e5b
--- /dev/null
+++ b/src/GithubMonologServiceProvider.php
@@ -0,0 +1,46 @@
+app->bind(StackTraceFormatter::class);
+ $this->app->bind(StubLoader::class);
+ $this->app->bind(ExceptionFormatter::class, function ($app) {
+ return new ExceptionFormatter(
+ stackTraceFormatter: $app->make(StackTraceFormatter::class),
+ );
+ });
+
+ $this->app->singleton(TemplateRenderer::class, function ($app) {
+ return new TemplateRenderer(
+ exceptionFormatter: $app->make(ExceptionFormatter::class),
+ stubLoader: $app->make(StubLoader::class),
+ );
+ });
+
+ $this->app->singleton(IssueFormatter::class, function ($app) {
+ return new IssueFormatter(
+ templateRenderer: $app->make(TemplateRenderer::class),
+ );
+ });
+ }
+
+ public function boot(): void
+ {
+ if ($this->app->runningInConsole()) {
+ $this->publishes([
+ __DIR__.'/../resources/views' => resource_path('views/vendor/github-monolog'),
+ ], 'github-monolog-views');
+ }
+ }
+}
diff --git a/src/Issues/Formatter.php b/src/Issues/Formatter.php
index 5874f34..66e5737 100644
--- a/src/Issues/Formatter.php
+++ b/src/Issues/Formatter.php
@@ -1,30 +1,18 @@
extra['github_issue_signature'])) {
@@ -34,26 +22,17 @@ public function format(LogRecord $record): Formatted
$exception = $this->getException($record);
return new Formatted(
- title: $this->formatTitle($record, $exception),
- body: $this->formatBody($record, $record->extra['github_issue_signature'], $exception),
- comment: $this->formatComment($record, $exception),
+ title: $this->templateRenderer->renderTitle($record, $exception),
+ body: $this->templateRenderer->render($this->templateRenderer->getIssueStub(), $record, $record->extra['github_issue_signature'], $exception),
+ comment: $this->templateRenderer->render($this->templateRenderer->getCommentStub(), $record, null, $exception),
);
}
- /**
- * Formats a set of log records.
- *
- * @param array $records A set of records to format
- * @return array The formatted set of records
- */
public function formatBatch(array $records): array
{
return array_map([$this, 'format'], $records);
}
- /**
- * Check if the record contains an error exception
- */
private function hasErrorException(LogRecord $record): bool
{
return $record->level->value >= \Monolog\Level::Error->value
@@ -61,231 +40,8 @@ private function hasErrorException(LogRecord $record): bool
&& $record->context['exception'] instanceof Throwable;
}
- /**
- * Get the exception from the record if it exists
- */
private function getException(LogRecord $record): ?Throwable
{
return $this->hasErrorException($record) ? $record->context['exception'] : null;
}
-
- private function formatTitle(LogRecord $record, ?Throwable $exception = null): string
- {
- if (! $exception) {
- return Str::of('[{level}] {message}')
- ->replace('{level}', $record->level->getName())
- ->replace('{message}', Str::limit($record->message, self::TITLE_MAX_LENGTH))
- ->toString();
- }
-
- $exceptionClass = (new ReflectionClass($exception))->getShortName();
- $file = Str::replace(base_path(), '', $exception->getFile());
-
- return Str::of('[{level}] {class} in {file}:{line} - {message}')
- ->replace('{level}', $record->level->getName())
- ->replace('{class}', $exceptionClass)
- ->replace('{file}', $file)
- ->replace('{line}', (string) $exception->getLine())
- ->replace('{message}', Str::limit($exception->getMessage(), self::TITLE_MAX_LENGTH))
- ->toString();
- }
-
- private function formatContent(LogRecord $record, ?Throwable $exception): string
- {
- return Str::of('')
- ->when($record->message, fn ($str, $message) => $str->append("**Message:**\n{$message}\n\n"))
- ->when(
- $exception,
- function (Stringable $str, Throwable $exception) {
- return $str->append(
- $this->renderExceptionDetails($this->formatExceptionDetails($exception)),
- $this->renderPreviousExceptions($this->formatPreviousExceptions($exception))
- );
- }
- )
- ->when(! empty($record->context), fn ($str, $context) => $str->append("**Context:**\n```json\n".json_encode(Arr::except($record->context, ['exception']), JSON_PRETTY_PRINT)."\n```\n\n"))
- ->when(! empty($record->extra), fn ($str, $extra) => $str->append("**Extra Data:**\n```json\n".json_encode($record->extra, JSON_PRETTY_PRINT)."\n```\n"))
- ->toString();
- }
-
- private function formatBody(LogRecord $record, string $signature, ?Throwable $exception): string
- {
- return Str::of("**Log Level:** {$record->level->getName()}\n\n")
- ->append($this->formatContent($record, $exception))
- ->append("\n\n")
- ->toString();
- }
-
- /**
- * Shamelessly stolen from Solo by @aarondfrancis
- *
- * See: https://github.com/aarondfrancis/solo/blob/main/src/Commands/EnhancedTailCommand.php
- */
- private function cleanStackTrace(string $stackTrace): string
- {
- return collect(explode("\n", $stackTrace))
- ->filter(fn ($line) => ! empty(trim($line)))
- ->map(function ($line) {
- if (trim($line) === '"}') {
- return '';
- }
-
- if (str_contains($line, '{"exception":"[object] ')) {
- return $this->formatInitialException($line);
- }
-
- // Not a stack frame line, return as is
- if (! Str::isMatch('/#[0-9]+ /', $line)) {
- return $line;
- }
-
- // Make the line shorter by removing the base path
- $line = str_replace(base_path(), '', $line);
-
- if (str_contains((string) $line, '/vendor/') && ! Str::isMatch("/BoundMethod\.php\([0-9]+\): App/", $line)) {
- return self::VENDOR_FRAME_PLACEHOLDER;
- }
-
- return $line;
- })
- ->pipe($this->modifyWrappedLines(...))
- ->join("\n");
- }
-
- public function formatInitialException($line): array
- {
- [$message, $exception] = explode('{"exception":"[object] ', $line);
-
- return [
- $message,
- $exception,
- ];
- }
-
- protected function modifyWrappedLines(Collection $lines): Collection
- {
- $hasVendorFrame = false;
-
- // After all the lines have been wrapped, we look through them
- // to collapse consecutive vendor frames into a single line.
- return $lines->filter(function ($line) use (&$hasVendorFrame) {
- $isVendorFrame = str_contains($line, '[Vendor frames]');
-
- if ($isVendorFrame) {
- // Skip the line if a vendor frame has already been added.
- if ($hasVendorFrame) {
- return false;
- }
- // Otherwise, mark that a vendor frame has been added.
- $hasVendorFrame = true;
- } else {
- // Reset the flag if the current line is not a vendor frame.
- $hasVendorFrame = false;
- }
-
- return true;
- });
- }
-
- private function formatExceptionDetails(Throwable $exception): array
- {
- $header = sprintf(
- '[%s] %s: %s at %s:%d',
- $this->getCurrentDateTime(),
- (new ReflectionClass($exception))->getShortName(),
- $exception->getMessage(),
- str_replace(base_path(), '', $exception->getFile()),
- $exception->getLine()
- );
-
- return [
- 'message' => $exception->getMessage(),
- 'stack_trace' => $header."\n[stacktrace]\n".$this->cleanStackTrace($exception->getTraceAsString()),
- 'full_stack_trace' => $header."\n[stacktrace]\n".$exception->getTraceAsString(),
- ];
- }
-
- private function getCurrentDateTime(): string
- {
- return now()->format('Y-m-d H:i:s');
- }
-
- private function formatPreviousExceptions(Throwable $exception): array
- {
- $previous = $exception->getPrevious();
- if (! $previous) {
- return [];
- }
-
- return collect()
- ->range(1, self::MAX_PREVIOUS_EXCEPTIONS)
- ->map(function ($count) use (&$previous) {
- if (! $previous) {
- return null;
- }
-
- $current = $previous;
- $previous = $previous->getPrevious();
-
- return [
- 'count' => $count,
- 'type' => get_class($current),
- 'details' => $this->formatExceptionDetails($current),
- ];
- })
- ->filter()
- ->values()
- ->all();
- }
-
- private function renderExceptionDetails(array $details): string
- {
- $content = sprintf("**Simplified Stack Trace:**\n```php\n%s\n```\n\n", $details['stack_trace']);
-
- // Add the complete stack trace in details tag
- $content .= "**Complete Stack Trace:**\n";
- $content .= "\nView full trace
\n\n";
- $content .= sprintf("```php\n%s\n```\n", str_replace(base_path(), '', $details['full_stack_trace'] ?? $details['stack_trace']));
- $content .= " \n\n";
-
- return $content;
- }
-
- private function renderPreviousExceptions(array $exceptions): string
- {
- if (empty($exceptions)) {
- return '';
- }
-
- $content = "\nPrevious Exceptions
\n\n";
-
- foreach ($exceptions as $exception) {
- $content .= "### Previous Exception #{$exception['count']}\n";
- $content .= "**Type:** {$exception['type']}\n\n";
- $content .= $this->renderExceptionDetails($exception['details']);
- }
-
- if (count($exceptions) === self::MAX_PREVIOUS_EXCEPTIONS) {
- $content .= "\n> Note: Additional previous exceptions were truncated\n";
- }
-
- $content .= " \n\n";
-
- return $content;
- }
-
- /**
- * Formats a log record for a comment on an existing issue.
- *
- * @param LogRecord $record A record to format
- * @return string The formatted comment
- */
- public function formatComment(LogRecord $record, ?Throwable $exception): string
- {
- $body = "# New Occurrence\n\n";
- $body .= "**Log Level:** {$record->level->getName()}\n\n";
- $body .= $this->formatContent($record, $exception);
-
- return $body;
- }
}
diff --git a/src/Issues/Formatters/ExceptionFormatter.php b/src/Issues/Formatters/ExceptionFormatter.php
new file mode 100644
index 0000000..aa89e67
--- /dev/null
+++ b/src/Issues/Formatters/ExceptionFormatter.php
@@ -0,0 +1,66 @@
+context['exception'] ?? null;
+ if (! $exception instanceof Throwable) {
+ return [];
+ }
+
+ $header = $this->formatHeader($exception);
+ $stackTrace = $exception->getTraceAsString();
+
+ return [
+ 'message' => $exception->getMessage(),
+ 'simplified_stack_trace' => $header."\n[stacktrace]\n".$this->stackTraceFormatter->format($stackTrace),
+ 'full_stack_trace' => $header."\n[stacktrace]\n".$stackTrace,
+ ];
+ }
+
+ public function formatBatch(array $records): array
+ {
+ return array_map([$this, 'format'], $records);
+ }
+
+ public function formatTitle(Throwable $exception, string $level): string
+ {
+ $exceptionClass = (new ReflectionClass($exception))->getShortName();
+ $file = Str::replace(base_path(), '', $exception->getFile());
+
+ return Str::of('[{level}] {class} in {file}:{line} - {message}')
+ ->replace('{level}', $level)
+ ->replace('{class}', $exceptionClass)
+ ->replace('{file}', $file)
+ ->replace('{line}', (string) $exception->getLine())
+ ->replace('{message}', Str::limit($exception->getMessage(), self::TITLE_MAX_LENGTH))
+ ->toString();
+ }
+
+ private function formatHeader(Throwable $exception): string
+ {
+ return sprintf(
+ '[%s] %s: %s at %s:%d',
+ now()->format('Y-m-d H:i:s'),
+ (new ReflectionClass($exception))->getShortName(),
+ $exception->getMessage(),
+ str_replace(base_path(), '', $exception->getFile()),
+ $exception->getLine()
+ );
+ }
+}
diff --git a/src/Issues/Formatted.php b/src/Issues/Formatters/Formatted.php
similarity index 76%
rename from src/Issues/Formatted.php
rename to src/Issues/Formatters/Formatted.php
index f84e647..22c24ea 100644
--- a/src/Issues/Formatted.php
+++ b/src/Issues/Formatters/Formatted.php
@@ -1,6 +1,6 @@
extra['github_issue_signature'])) {
+ throw new \RuntimeException('Record is missing github_issue_signature in extra data. Make sure the DeduplicationHandler is configured correctly.');
+ }
+
+ $exception = $this->getException($record);
+
+ return new Formatted(
+ title: $this->templateRenderer->renderTitle($record, $exception),
+ body: $this->templateRenderer->render($this->templateRenderer->getIssueStub(), $record, $record->extra['github_issue_signature'], $exception),
+ comment: $this->templateRenderer->render($this->templateRenderer->getCommentStub(), $record, null, $exception),
+ );
+ }
+
+ public function formatBatch(array $records): array
+ {
+ return array_map([$this, 'format'], $records);
+ }
+
+ private function hasErrorException(LogRecord $record): bool
+ {
+ return $record->level->value >= \Monolog\Level::Error->value
+ && isset($record->context['exception'])
+ && $record->context['exception'] instanceof Throwable;
+ }
+
+ private function getException(LogRecord $record): ?Throwable
+ {
+ return $this->hasErrorException($record) ? $record->context['exception'] : null;
+ }
+}
diff --git a/src/Issues/Formatters/StackTraceFormatter.php b/src/Issues/Formatters/StackTraceFormatter.php
new file mode 100644
index 0000000..6469a64
--- /dev/null
+++ b/src/Issues/Formatters/StackTraceFormatter.php
@@ -0,0 +1,70 @@
+filter(fn ($line) => ! empty(trim($line)))
+ ->map(function ($line) {
+ if (trim($line) === '"}') {
+ return '';
+ }
+
+ if (str_contains($line, '{"exception":"[object] ')) {
+ return $this->formatInitialException($line);
+ }
+
+ if (! Str::isMatch('/#[0-9]+ /', $line)) {
+ return $line;
+ }
+
+ $line = str_replace(base_path(), '', $line);
+
+ if (str_contains((string) $line, '/vendor/') && ! Str::isMatch("/BoundMethod\.php\([0-9]+\): App/", $line)) {
+ return self::VENDOR_FRAME_PLACEHOLDER;
+ }
+
+ return $line;
+ })
+ ->pipe($this->collapseVendorFrames(...))
+ ->join("\n");
+ }
+
+ private function formatInitialException(string $line): array
+ {
+ [$message, $exception] = explode('{"exception":"[object] ', $line);
+
+ return [
+ $message,
+ $exception,
+ ];
+ }
+
+ private function collapseVendorFrames(Collection $lines): Collection
+ {
+ $hasVendorFrame = false;
+
+ return $lines->filter(function ($line) use (&$hasVendorFrame) {
+ $isVendorFrame = str_contains($line, '[Vendor frames]');
+
+ if ($isVendorFrame) {
+ if ($hasVendorFrame) {
+ return false;
+ }
+ $hasVendorFrame = true;
+ } else {
+ $hasVendorFrame = false;
+ }
+
+ return true;
+ });
+ }
+}
diff --git a/src/Issues/Handler.php b/src/Issues/Handler.php
index 9af97f9..6069ad0 100644
--- a/src/Issues/Handler.php
+++ b/src/Issues/Handler.php
@@ -2,15 +2,20 @@
namespace Naoray\LaravelGithubMonolog\Issues;
+use Illuminate\Http\Client\PendingRequest;
+use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Level;
use Monolog\LogRecord;
+use Naoray\LaravelGithubMonolog\Issues\Formatters\Formatted;
class Handler extends AbstractProcessingHandler
{
private const DEFAULT_LABEL = 'github-issue-logger';
+ private PendingRequest $client;
+
/**
* @param string $repo The GitHub repository in "owner/repo" format
* @param string $token Your GitHub Personal Access Token
@@ -30,6 +35,7 @@ public function __construct(
$this->repo = $repo;
$this->token = $token;
$this->labels = array_unique(array_merge([self::DEFAULT_LABEL], $labels));
+ $this->client = Http::withToken($this->token)->baseUrl('https://api.github.com');
}
/**
@@ -42,15 +48,24 @@ protected function write(LogRecord $record): void
}
$formatted = $record->formatted;
- $existingIssue = $this->findExistingIssue($record);
- if ($existingIssue) {
- $this->commentOnIssue($existingIssue['number'], $formatted);
+ try {
+ $existingIssue = $this->findExistingIssue($record);
- return;
- }
+ if ($existingIssue) {
+ $this->commentOnIssue($existingIssue['number'], $formatted);
+
+ return;
+ }
+
+ $this->createIssue($formatted);
+ } catch (RequestException $e) {
+ if ($e->response->serverError()) {
+ throw $e;
+ }
- $this->createIssue($formatted);
+ $this->createFallbackIssue($formatted, $e->response->body());
+ }
}
/**
@@ -62,16 +77,12 @@ private function findExistingIssue(LogRecord $record): ?array
throw new \RuntimeException('Record is missing github_issue_signature in extra data. Make sure the DeduplicationHandler is configured correctly.');
}
- $response = Http::withToken($this->token)
- ->get('https://api.github.com/search/issues', [
+ return $this->client
+ ->get('/search/issues', [
'q' => "repo:{$this->repo} is:issue is:open label:".self::DEFAULT_LABEL." \"Signature: {$record->extra['github_issue_signature']}\"",
- ]);
-
- if ($response->failed()) {
- throw new \RuntimeException('Failed to search GitHub issues: '.$response->body());
- }
-
- return $response->json('items.0', null);
+ ])
+ ->throw()
+ ->json('items.0', null);
}
/**
@@ -79,14 +90,11 @@ private function findExistingIssue(LogRecord $record): ?array
*/
private function commentOnIssue(int $issueNumber, Formatted $formatted): void
{
- $response = Http::withToken($this->token)
- ->post("https://api.github.com/repos/{$this->repo}/issues/{$issueNumber}/comments", [
+ $this->client
+ ->post("/repos/{$this->repo}/issues/{$issueNumber}/comments", [
'body' => $formatted->comment,
- ]);
-
- if ($response->failed()) {
- throw new \RuntimeException('Failed to comment on GitHub issue: '.$response->body());
- }
+ ])
+ ->throw();
}
/**
@@ -94,15 +102,26 @@ private function commentOnIssue(int $issueNumber, Formatted $formatted): void
*/
private function createIssue(Formatted $formatted): void
{
- $response = Http::withToken($this->token)
- ->post("https://api.github.com/repos/{$this->repo}/issues", [
+ $this->client
+ ->post("/repos/{$this->repo}/issues", [
'title' => $formatted->title,
'body' => $formatted->body,
'labels' => $this->labels,
- ]);
+ ])
+ ->throw();
+ }
- if ($response->failed()) {
- throw new \RuntimeException('Failed to create GitHub issue: '.$response->body());
- }
+ /**
+ * Create a fallback issue when the main issue creation fails
+ */
+ private function createFallbackIssue(Formatted $formatted, string $errorMessage): void
+ {
+ $this->client
+ ->post("/repos/{$this->repo}/issues", [
+ 'title' => '[GitHub Monolog Error] '.$formatted->title,
+ 'body' => "**Original Error Message:**\n{$formatted->body}\n\n**Integration Error:**\n{$errorMessage}",
+ 'labels' => array_merge($this->labels, ['monolog-integration-error']),
+ ])
+ ->throw();
}
}
diff --git a/src/Issues/StubLoader.php b/src/Issues/StubLoader.php
new file mode 100644
index 0000000..eda24f3
--- /dev/null
+++ b/src/Issues/StubLoader.php
@@ -0,0 +1,25 @@
+issueStub = $this->stubLoader->load('issue');
+ $this->commentStub = $this->stubLoader->load('comment');
+ $this->previousExceptionStub = $this->stubLoader->load('previous_exception');
+ }
+
+ public function render(string $template, LogRecord $record, ?string $signature = null, ?Throwable $exception = null): string
+ {
+ $replacements = $this->buildReplacements($record, $signature, $exception);
+
+ return Str::of($template)
+ ->replace(array_keys($replacements), array_values($replacements))
+ ->toString();
+ }
+
+ public function renderTitle(LogRecord $record, ?Throwable $exception = null): string
+ {
+ if (! $exception) {
+ return Str::of('[{level}] {message}')
+ ->replace('{level}', $record->level->getName())
+ ->replace('{message}', Str::limit($record->message, self::TITLE_MAX_LENGTH))
+ ->toString();
+ }
+
+ return $this->exceptionFormatter->formatTitle($exception, $record->level->getName());
+ }
+
+ public function getIssueStub(): string
+ {
+ return $this->issueStub;
+ }
+
+ public function getCommentStub(): string
+ {
+ return $this->commentStub;
+ }
+
+ private function buildReplacements(LogRecord $record, ?string $signature, ?Throwable $exception): array
+ {
+ $exceptionDetails = $exception ? $this->exceptionFormatter->format($record) : [];
+
+ return array_filter([
+ '{level}' => $record->level->getName(),
+ '{message}' => $record->message,
+ '{simplified_stack_trace}' => $exceptionDetails['simplified_stack_trace'] ?? '',
+ '{full_stack_trace}' => $exceptionDetails['full_stack_trace'] ?? '',
+ '{previous_exceptions}' => $exception ? $this->formatPrevious($exception) : '',
+ '{context}' => $this->formatContext($record->context),
+ '{extra}' => $this->formatExtra($record->extra),
+ '{signature}' => $signature,
+ ]);
+ }
+
+ private function formatPrevious(Throwable $exception): string
+ {
+ $previous = $exception->getPrevious();
+ if (! $previous) {
+ return '';
+ }
+
+ $exceptions = collect()
+ ->range(1, self::MAX_PREVIOUS_EXCEPTIONS)
+ ->map(function ($count) use (&$previous) {
+ if (! $previous) {
+ return null;
+ }
+
+ $current = $previous;
+ $previous = $previous->getPrevious();
+
+ $details = $this->exceptionFormatter->format(new LogRecord(
+ datetime: new \DateTimeImmutable,
+ channel: 'github',
+ level: \Monolog\Level::Error,
+ message: '',
+ context: ['exception' => $current],
+ extra: []
+ ));
+
+ return Str::of($this->previousExceptionStub)
+ ->replace(
+ ['{count}', '{type}', '{simplified_stack_trace}', '{full_stack_trace}'],
+ [$count, get_class($current), $details['simplified_stack_trace'], str_replace(base_path(), '', $details['full_stack_trace'])]
+ )
+ ->toString();
+ })
+ ->filter()
+ ->join("\n\n");
+
+ if (empty($exceptions)) {
+ return '';
+ }
+
+ if ($previous) {
+ $exceptions .= "\n\n> Note: Additional previous exceptions were truncated\n";
+ }
+
+ return $exceptions;
+ }
+
+ private function formatContext(array $context): string
+ {
+ if (empty($context)) {
+ return '';
+ }
+
+ return sprintf(
+ "**Context:**\n```json\n%s\n```\n",
+ json_encode(Arr::except($context, ['exception']), JSON_PRETTY_PRINT)
+ );
+ }
+
+ private function formatExtra(array $extra): string
+ {
+ if (empty($extra)) {
+ return '';
+ }
+
+ return sprintf(
+ "**Extra Data:**\n```json\n%s\n```",
+ json_encode($extra, JSON_PRETTY_PRINT)
+ );
+ }
+}
diff --git a/tests/Deduplication/CacheManagerTest.php b/tests/Deduplication/CacheManagerTest.php
new file mode 100644
index 0000000..09399e3
--- /dev/null
+++ b/tests/Deduplication/CacheManagerTest.php
@@ -0,0 +1,56 @@
+store = 'array';
+ $this->prefix = 'test:';
+ $this->ttl = 60;
+
+ $this->manager = new CacheManager(
+ store: $this->store,
+ prefix: $this->prefix,
+ ttl: $this->ttl
+ );
+
+ Cache::store($this->store)->clear();
+});
+
+afterEach(function () {
+ Carbon::setTestNow();
+ Cache::store($this->store)->clear();
+});
+
+test('it can add and check signatures', function () {
+ $signature = 'test-signature';
+
+ expect($this->manager->has($signature))->toBeFalse();
+
+ $this->manager->add($signature);
+
+ expect($this->manager->has($signature))->toBeTrue();
+});
+
+test('it expires old entries', function () {
+ $signature = 'test-signature';
+ $this->manager->add($signature);
+
+ // Travel forward in time past TTL
+ travel($this->ttl + 1)->seconds();
+
+ expect($this->manager->has($signature))->toBeFalse();
+});
+
+test('it keeps valid entries', function () {
+ $signature = 'test-signature';
+ $this->manager->add($signature);
+
+ // Travel forward in time but not past TTL
+ travel($this->ttl - 1)->seconds();
+
+ expect($this->manager->has($signature))->toBeTrue();
+});
diff --git a/tests/Deduplication/DeduplicationHandlerTest.php b/tests/Deduplication/DeduplicationHandlerTest.php
index d54634e..45c2949 100644
--- a/tests/Deduplication/DeduplicationHandlerTest.php
+++ b/tests/Deduplication/DeduplicationHandlerTest.php
@@ -1,27 +1,23 @@
testHandler = new TestHandler;
- $this->tempFile = sys_get_temp_dir().'/dedup-test-'.uniqid().'.log';
-});
-
-afterEach(function () {
- @unlink($this->tempFile);
+ Cache::store('array')->clear();
});
test('deduplication respects time window', function () {
- $store = new FileStore($this->tempFile, time: 1);
$handler = new DeduplicationHandler(
handler: $this->testHandler,
- store: $store,
signatureGenerator: new DefaultSignatureGenerator,
+ store: 'array',
+ ttl: 1
);
$record = createLogRecord();
@@ -38,11 +34,10 @@
});
test('deduplicates records with same signature', function () {
- $store = new FileStore($this->tempFile);
$handler = new DeduplicationHandler(
handler: $this->testHandler,
- store: $store,
- signatureGenerator: new DefaultSignatureGenerator
+ signatureGenerator: new DefaultSignatureGenerator,
+ store: 'array'
);
$record = createLogRecord();
@@ -54,11 +49,10 @@
});
test('different messages create different signatures', function () {
- $store = new FileStore($this->tempFile);
$handler = new DeduplicationHandler(
handler: $this->testHandler,
- store: $store,
- signatureGenerator: new DefaultSignatureGenerator
+ signatureGenerator: new DefaultSignatureGenerator,
+ store: 'array'
);
$record1 = createLogRecord('First message');
diff --git a/tests/Deduplication/Stores/AbstractStoreTest.php b/tests/Deduplication/Stores/AbstractStoreTest.php
deleted file mode 100644
index 0ed3d6d..0000000
--- a/tests/Deduplication/Stores/AbstractStoreTest.php
+++ /dev/null
@@ -1,76 +0,0 @@
-createStore();
- $record = $this->createLogRecord();
-
- $store->add($record, 'test-signature');
- $entries = $store->get();
-
- $this->assertCount(1, $entries);
- $this->assertStringEndsWith('test-signature', $entries[0]);
- }
-
- #[Test]
- public function it_removes_expired_entries(): void
- {
- $store = $this->createStore(time: 1); // 1 second expiry
- $record = $this->createLogRecord();
-
- $store->add($record, 'test-signature');
- sleep(2); // Wait for entry to expire
-
- $this->assertEmpty($store->get());
- }
-
- #[Test]
- public function it_keeps_valid_entries(): void
- {
- $store = $this->createStore(time: 5);
- $record = $this->createLogRecord();
-
- $store->add($record, 'test-signature');
- $entries = $store->get();
-
- $this->assertCount(1, $entries);
- }
-
- #[Test]
- public function it_handles_multiple_entries(): void
- {
- $store = $this->createStore();
- $record1 = $this->createLogRecord('test1');
- $record2 = $this->createLogRecord('test2');
-
- $store->add($record1, 'signature1');
- $store->add($record2, 'signature2');
-
- $entries = $store->get();
- $this->assertCount(2, $entries);
- }
-
- abstract protected function createStore(string $prefix = 'test:', int $time = 60): mixed;
-}
diff --git a/tests/Deduplication/Stores/DatabaseStoreTest.php b/tests/Deduplication/Stores/DatabaseStoreTest.php
deleted file mode 100644
index 44beaa9..0000000
--- a/tests/Deduplication/Stores/DatabaseStoreTest.php
+++ /dev/null
@@ -1,98 +0,0 @@
-set('database.default', 'sqlite');
- config()->set('database.connections.sqlite', [
- 'driver' => 'sqlite',
- 'database' => ':memory:',
- 'prefix' => '',
- ]);
-});
-
-afterEach(function () {
- Schema::dropIfExists('github_monolog_deduplication');
- Schema::dropIfExists('custom_dedup');
-});
-
-// Base Store Tests
-test('it can add and retrieve entries', function () {
- $store = createDatabaseStore();
- $record = createLogRecord();
- $signature = 'test-signature';
-
- $store->add($record, $signature);
-
- expect($store->get())->toHaveCount(1);
-});
-
-test('it removes expired entries', function () {
- $store = createDatabaseStore(time: 1);
- $record = createLogRecord();
- $signature = 'test-signature';
-
- $store->add($record, $signature);
- travel(2)->seconds();
-
- expect($store->get())->toBeEmpty();
-});
-
-test('it keeps valid entries', function () {
- $store = createDatabaseStore();
- $record = createLogRecord();
- $signature = 'test-signature';
-
- $store->add($record, $signature);
-
- expect($store->get())->toHaveCount(1);
-});
-
-test('it handles multiple entries', function () {
- $store = createDatabaseStore();
- $record = createLogRecord();
-
- $store->add($record, 'signature-1');
- $store->add($record, 'signature-2');
-
- expect($store->get())->toHaveCount(2);
-});
-
-// DatabaseStore Specific Tests
-test('it creates table if not exists', function () {
- $store = createDatabaseStore();
- expect(Schema::connection('sqlite')->hasTable('github_monolog_deduplication'))->toBeTrue();
-});
-
-test('it can use custom table', function () {
- $store = new DatabaseStore(
- connection: 'sqlite',
- table: 'custom_dedup',
- time: 60
- );
-
- $record = createLogRecord();
- $signature = 'test-signature';
-
- $store->add($record, $signature);
-
- expect($store->get())->toHaveCount(1);
- expect(Schema::connection('sqlite')->hasTable('custom_dedup'))->toBeTrue();
-});
-
-test('it cleans up expired entries from database', function () {
- $store = createDatabaseStore(time: 1);
- $record = createLogRecord();
-
- $store->add($record, 'signature-1');
- $store->add($record, 'signature-2');
- travel(2)->seconds();
-
- $store->cleanup();
-
- expect($store->get())->toBeEmpty();
-});
diff --git a/tests/Deduplication/Stores/FileStoreTest.php b/tests/Deduplication/Stores/FileStoreTest.php
deleted file mode 100644
index 0182358..0000000
--- a/tests/Deduplication/Stores/FileStoreTest.php
+++ /dev/null
@@ -1,120 +0,0 @@
-testPath = storage_path('logs/test-dedup.log');
- File::delete($this->testPath);
-});
-
-afterEach(function () {
- File::delete($this->testPath);
-});
-
-// Base Store Tests
-test('it can add and retrieve entries', function () {
- $store = createFileStore();
- $record = createLogRecord();
-
- $store->add($record, 'test-signature');
- $entries = $store->get();
-
- expect($entries)->toHaveCount(1)
- ->and($entries[0])->toEndWith('test-signature');
-});
-
-test('it removes expired entries', function () {
- $store = createFileStore(time: 1);
- $record = createLogRecord();
-
- $store->add($record, 'test-signature');
-
- travel(2)->seconds();
-
- $store->cleanup();
- expect($store->get())->toBeEmpty();
-});
-
-test('it keeps valid entries', function () {
- $store = createFileStore(time: 5);
- $record = createLogRecord();
-
- $store->add($record, 'test-signature');
- $entries = $store->get();
-
- expect($entries)->toHaveCount(1);
-});
-
-test('it handles multiple entries', function () {
- $store = createFileStore();
- $record1 = createLogRecord('test1');
- $record2 = createLogRecord('test2');
-
- $store->add($record1, 'signature1');
- $store->add($record2, 'signature2');
-
- expect($store->get())->toHaveCount(2);
-});
-
-// FileStore Specific Tests
-test('it creates directory if not exists', function () {
- $path = storage_path('logs/nested/test-dedup.log');
- File::deleteDirectory(dirname($path));
-
- new FileStore($path);
-
- expect(File::exists(dirname($path)))->toBeTrue();
- File::deleteDirectory(dirname($path));
-});
-
-test('it handles concurrent access', function () {
- $store1 = createFileStore();
- $store2 = createFileStore();
- $record = createLogRecord();
-
- $store1->add($record, 'signature1');
- $store2->add($record, 'signature2');
-
- expect($store1->get())->toHaveCount(2);
-});
-
-test('it handles corrupted file', function () {
- File::put(test()->testPath, 'invalid content');
-
- $store = createFileStore();
- $record = createLogRecord();
-
- $store->add($record, 'test-signature');
- $entries = $store->get();
-
- expect($entries)->not->toBeEmpty()
- ->and($entries[array_key_first($entries)])->toEndWith('test-signature');
-});
-
-test('it handles file permissions', function () {
- $store = createFileStore();
- $record = createLogRecord();
-
- $store->add($record, 'test-signature');
-
- expect(substr(sprintf('%o', fileperms(test()->testPath)), -4))->toBe('0644');
-});
-
-test('it maintains file integrity after cleanup', function () {
- $store = createFileStore(time: 1);
- $record = createLogRecord();
-
- $store->add($record, 'signature1');
- travel(2)->seconds();
- $store->add($record, 'signature2');
-
- $store->cleanup();
-
- $content = File::get(test()->testPath);
-
- expect(explode(PHP_EOL, trim($content)))->toHaveCount(1)
- ->and($content)->toEndWith('signature2');
-});
diff --git a/tests/Deduplication/Stores/RedisStoreTest.php b/tests/Deduplication/Stores/RedisStoreTest.php
deleted file mode 100644
index 48ab731..0000000
--- a/tests/Deduplication/Stores/RedisStoreTest.php
+++ /dev/null
@@ -1,124 +0,0 @@
-set('database.redis.default', [
- 'host' => env('REDIS_HOST', '127.0.0.1'),
- 'password' => env('REDIS_PASSWORD'),
- 'port' => env('REDIS_PORT', 6379),
- 'database' => 0,
- ]);
-
- // Configure second Redis connection for testing
- config()->set('database.redis.other', [
- 'host' => env('REDIS_HOST', '127.0.0.1'),
- 'password' => env('REDIS_PASSWORD'),
- 'port' => env('REDIS_PORT', 6379),
- 'database' => 1,
- ]);
-
- // Clear test keys
- Redis::del('test:dedup');
- Redis::del('custom:dedup');
- Redis::connection('other')->del('test:dedup');
-});
-
-afterEach(function () {
- Redis::del('test:dedup');
- Redis::del('custom:dedup');
- Redis::connection('other')->del('test:dedup');
-});
-
-// Base Store Tests
-test('it can add and retrieve entries', function () {
- $store = createRedisStore();
- $record = createLogRecord();
-
- $store->add($record, 'test-signature');
- $entries = $store->get();
-
- expect($entries)->toHaveCount(1)
- ->and($entries[0])->toEndWith('test-signature');
-});
-
-test('it removes expired entries', function () {
- $store = createRedisStore(time: 1);
- $record = createLogRecord();
-
- $store->add($record, 'test-signature');
-
- travel(2)->seconds();
-
- $store->cleanup();
-
- expect($store->get())->toBeEmpty();
-});
-
-test('it keeps valid entries', function () {
- $store = createRedisStore(time: 5);
- $record = createLogRecord();
-
- $store->add($record, 'test-signature');
- $entries = $store->get();
-
- expect($entries)->toHaveCount(1);
-});
-
-test('it handles multiple entries', function () {
- $store = createRedisStore();
- $record1 = createLogRecord('test1');
- $record2 = createLogRecord('test2');
-
- $store->add($record1, 'signature1');
- $store->add($record2, 'signature2');
-
- expect($store->get())->toHaveCount(2);
-});
-
-// Redis Store Specific Tests
-test('it uses correct redis key', function () {
- $store = createRedisStore('custom:');
- $record = createLogRecord();
-
- $store->add($record, 'test-signature');
-
- expect(Redis::exists('custom:dedup'))->toBeGreaterThan(0);
-});
-
-test('it can use different redis connection', function () {
- $store = new RedisStore(
- connection: 'other',
- prefix: 'test:',
- time: 60
- );
-
- $record = createLogRecord();
- $store->add($record, 'test-signature');
-
- expect(Redis::connection('other')->exists('test:dedup'))->toBeGreaterThan(0);
-});
-
-test('it properly cleans up expired entries', function () {
- $store = createRedisStore(time: 1);
- $record = createLogRecord();
-
- // Add an entry that will expire
- $store->add($record, 'test-signature');
-
- // Verify it exists
- expect($store->get())->toHaveCount(1);
-
- travel(2)->seconds();
-
- $store->cleanup();
-
- // Verify Redis directly
- expect(Redis::zcount($store->getKey(), '-inf', '+inf'))
- ->toBe(0)
- ->and($store->get())->toBeEmpty();
-});
diff --git a/tests/GithubIssueHandlerFactoryTest.php b/tests/GithubIssueHandlerFactoryTest.php
index c270ee3..62c6eb8 100644
--- a/tests/GithubIssueHandlerFactoryTest.php
+++ b/tests/GithubIssueHandlerFactoryTest.php
@@ -4,11 +4,8 @@
use Monolog\Logger;
use Naoray\LaravelGithubMonolog\Deduplication\DeduplicationHandler;
use Naoray\LaravelGithubMonolog\Deduplication\DefaultSignatureGenerator;
-use Naoray\LaravelGithubMonolog\Deduplication\Stores\DatabaseStore;
-use Naoray\LaravelGithubMonolog\Deduplication\Stores\FileStore;
-use Naoray\LaravelGithubMonolog\Deduplication\Stores\RedisStore;
use Naoray\LaravelGithubMonolog\GithubIssueHandlerFactory;
-use Naoray\LaravelGithubMonolog\Issues\Formatter;
+use Naoray\LaravelGithubMonolog\Issues\Formatters\IssueFormatter;
use Naoray\LaravelGithubMonolog\Issues\Handler;
function getWrappedHandler(DeduplicationHandler $handler): Handler
@@ -18,9 +15,9 @@ function getWrappedHandler(DeduplicationHandler $handler): Handler
return $reflection->getValue($handler);
}
-function getDeduplicationStore(DeduplicationHandler $handler): mixed
+function getCacheManager(DeduplicationHandler $handler): mixed
{
- $reflection = new ReflectionProperty($handler, 'store');
+ $reflection = new ReflectionProperty($handler, 'cache');
return $reflection->getValue($handler);
}
@@ -40,8 +37,7 @@ function getSignatureGenerator(DeduplicationHandler $handler): mixed
'labels' => ['test-label'],
];
- $this->signatureGenerator = new DefaultSignatureGenerator;
- $this->factory = new GithubIssueHandlerFactory($this->signatureGenerator);
+ $this->factory = app()->make(GithubIssueHandlerFactory::class);
});
test('it creates a logger with deduplication handler', function () {
@@ -65,7 +61,7 @@ function getSignatureGenerator(DeduplicationHandler $handler): mixed
->and($wrappedHandler->getLevel())
->toBe(Level::Error)
->and($wrappedHandler->getFormatter())
- ->toBeInstanceOf(Formatter::class);
+ ->toBeInstanceOf(IssueFormatter::class);
});
test('it throws exception when required config is missing', function () {
@@ -91,62 +87,45 @@ function getSignatureGenerator(DeduplicationHandler $handler): mixed
->and($flushOnOverflow)->toBeFalse();
});
-test('it can use file store driver', function () {
- $path = sys_get_temp_dir().'/dedup-test-'.uniqid().'.log';
-
+test('it uses configured cache store', function () {
$logger = ($this->factory)([
...$this->config,
'deduplication' => [
- 'store' => 'file',
- 'path' => $path,
+ 'store' => 'array',
+ 'prefix' => 'custom:',
+ 'time' => 120,
],
]);
/** @var DeduplicationHandler $handler */
$handler = $logger->getHandlers()[0];
- $store = getDeduplicationStore($handler);
-
- expect($store)->toBeInstanceOf(FileStore::class);
-});
-
-test('it can use redis store driver', function () {
- $this->config['deduplication'] = [
- 'store' => 'redis',
- 'connection' => 'default',
- ];
-
- $logger = ($this->factory)($this->config);
-
- /** @var DeduplicationHandler $handler */
- $handler = $logger->getHandlers()[0];
- $store = getDeduplicationStore($handler);
-
- expect($store)->toBeInstanceOf(RedisStore::class);
+ $cacheManager = getCacheManager($handler);
+ $store = (new ReflectionProperty($cacheManager, 'store'))->getValue($cacheManager);
+ $prefix = (new ReflectionProperty($cacheManager, 'prefix'))->getValue($cacheManager);
+ $ttl = (new ReflectionProperty($cacheManager, 'ttl'))->getValue($cacheManager);
+
+ expect($store)->toBe('array')
+ ->and($prefix)->toBe('custom:')
+ ->and($ttl)->toBe(120);
});
-test('it can use database store driver', function () {
- config()->set('database.connections.sqlite', [
- 'driver' => 'sqlite',
- 'database' => ':memory:',
- ]);
-
- $this->config['deduplication'] = [
- 'store' => 'database',
- 'connection' => 'sqlite',
- ];
-
+test('it uses default cache configuration', function () {
$logger = ($this->factory)($this->config);
/** @var DeduplicationHandler $handler */
$handler = $logger->getHandlers()[0];
- $store = getDeduplicationStore($handler);
-
- expect($store)->toBeInstanceOf(DatabaseStore::class);
+ $cacheManager = getCacheManager($handler);
+ $store = (new ReflectionProperty($cacheManager, 'store'))->getValue($cacheManager);
+ $prefix = (new ReflectionProperty($cacheManager, 'prefix'))->getValue($cacheManager);
+ $ttl = (new ReflectionProperty($cacheManager, 'ttl'))->getValue($cacheManager);
+
+ expect($store)->toBe(config('cache.default'))
+ ->and($prefix)->toBe('github-monolog:')
+ ->and($ttl)->toBe(60);
});
test('it uses same signature generator across components', function () {
- $factory = new GithubIssueHandlerFactory(new DefaultSignatureGenerator);
- $logger = $factory([
+ $logger = ($this->factory)([
'repo' => 'test/repo',
'token' => 'test-token',
]);
@@ -178,13 +157,3 @@ function getSignatureGenerator(DeduplicationHandler $handler): mixed
],
]))->toThrow(\InvalidArgumentException::class, 'Deduplication time must be a positive integer');
});
-
-test('it uses file store driver by default', function () {
- $logger = ($this->factory)($this->config);
-
- /** @var DeduplicationHandler $handler */
- $handler = $logger->getHandlers()[0];
- $store = getDeduplicationStore($handler);
-
- expect($store)->toBeInstanceOf(FileStore::class);
-});
diff --git a/tests/Issues/Formatters/ExceptionFormatterTest.php b/tests/Issues/Formatters/ExceptionFormatterTest.php
new file mode 100644
index 0000000..d99a26c
--- /dev/null
+++ b/tests/Issues/Formatters/ExceptionFormatterTest.php
@@ -0,0 +1,79 @@
+stackTraceFormatter = Mockery::mock(StackTraceFormatter::class);
+ $this->formatter = new ExceptionFormatter(
+ stackTraceFormatter: $this->stackTraceFormatter,
+ );
+});
+
+test('it formats exception details', function () {
+ $exception = new RuntimeException('Test exception');
+ $record = new LogRecord(
+ datetime: new DateTimeImmutable,
+ channel: 'test',
+ level: Level::Error,
+ message: 'Test message',
+ context: ['exception' => $exception],
+ extra: [],
+ );
+
+ $this->stackTraceFormatter->shouldReceive('format')
+ ->once()
+ ->with($exception->getTraceAsString())
+ ->andReturn('formatted stack trace');
+
+ $result = $this->formatter->format($record);
+
+ expect($result)
+ ->toBeArray()
+ ->toHaveKeys(['message', 'simplified_stack_trace', 'full_stack_trace'])
+ ->and($result['message'])->toBe('Test exception')
+ ->and($result['simplified_stack_trace'])->toContain('formatted stack trace')
+ ->and($result['full_stack_trace'])->toContain($exception->getTraceAsString());
+});
+
+test('it returns empty array for non-exception records', function () {
+ $record = new LogRecord(
+ datetime: new DateTimeImmutable,
+ channel: 'test',
+ level: Level::Error,
+ message: 'Test message',
+ context: [],
+ extra: [],
+ );
+
+ expect($this->formatter->format($record))->toBeArray()->toBeEmpty();
+});
+
+test('it formats exception title', function () {
+ $exception = new RuntimeException('Test exception');
+
+ $title = $this->formatter->formatTitle($exception, 'ERROR');
+
+ expect($title)
+ ->toContain('[ERROR]')
+ ->toContain('RuntimeException')
+ ->toContain('Test exception');
+});
+
+test('it truncates long exception messages in title', function () {
+ $longMessage = str_repeat('a', 150);
+ $exception = new RuntimeException($longMessage);
+
+ $title = $this->formatter->formatTitle($exception, 'ERROR');
+
+ // Title format: [ERROR] RuntimeException in /path/to/file.php:123 - {truncated_message}
+ // We check that the message part is truncated
+ expect($title)
+ ->toContain('[ERROR]')
+ ->toContain('RuntimeException')
+ ->toContain('...');
+});
diff --git a/tests/Issues/FormatterTest.php b/tests/Issues/Formatters/IssueFormatterTest.php
similarity index 89%
rename from tests/Issues/FormatterTest.php
rename to tests/Issues/Formatters/IssueFormatterTest.php
index d2ad9e1..0ebadfd 100644
--- a/tests/Issues/FormatterTest.php
+++ b/tests/Issues/Formatters/IssueFormatterTest.php
@@ -2,11 +2,14 @@
use Monolog\Level;
use Monolog\LogRecord;
-use Naoray\LaravelGithubMonolog\Issues\Formatted;
-use Naoray\LaravelGithubMonolog\Issues\Formatter;
+use Naoray\LaravelGithubMonolog\Issues\Formatters\Formatted;
+use Naoray\LaravelGithubMonolog\Issues\Formatters\IssueFormatter;
+
+beforeEach(function () {
+ $this->formatter = app()->make(IssueFormatter::class);
+});
test('it formats basic log records', function () {
- $formatter = new Formatter;
$record = new LogRecord(
datetime: new DateTimeImmutable,
channel: 'test',
@@ -16,7 +19,7 @@
extra: ['github_issue_signature' => 'test-signature']
);
- $formatted = $formatter->format($record);
+ $formatted = $this->formatter->format($record);
expect($formatted)
->toBeInstanceOf(Formatted::class)
@@ -26,7 +29,6 @@
});
test('it formats exceptions with file and line information', function () {
- $formatter = new Formatter;
$exception = new RuntimeException('Test exception');
$record = new LogRecord(
datetime: new DateTimeImmutable,
@@ -37,7 +39,7 @@
extra: ['github_issue_signature' => 'test-signature']
);
- $formatted = $formatter->format($record);
+ $formatted = $this->formatter->format($record);
expect($formatted->title)
->toContain('RuntimeException')
@@ -48,7 +50,6 @@
});
test('it truncates long titles', function () {
- $formatter = new Formatter;
$longMessage = str_repeat('a', 90);
$record = new LogRecord(
datetime: new DateTimeImmutable,
@@ -59,13 +60,12 @@
extra: ['github_issue_signature' => 'test-signature']
);
- $formatted = $formatter->format($record);
+ $formatted = $this->formatter->format($record);
expect(mb_strlen($formatted->title))->toBeLessThanOrEqual(100);
});
test('it includes context data in formatted output', function () {
- $formatter = new Formatter;
$record = new LogRecord(
datetime: new DateTimeImmutable,
channel: 'test',
@@ -75,7 +75,7 @@
extra: ['github_issue_signature' => 'test-signature']
);
- $formatted = $formatter->format($record);
+ $formatted = $this->formatter->format($record);
expect($formatted->body)
->toContain('"user_id": 123')
@@ -83,8 +83,6 @@
});
test('it formats stack traces with collapsible vendor frames', function () {
- $formatter = new Formatter;
-
$exception = new Exception('Test exception');
$reflection = new ReflectionClass($exception);
$traceProperty = $reflection->getProperty('trace');
@@ -127,7 +125,7 @@
extra: ['github_issue_signature' => 'test-signature']
);
- $formatted = $formatter->format($record);
+ $formatted = $this->formatter->format($record);
// Verify that app frames are directly visible
expect($formatted->body)
diff --git a/tests/Issues/Formatters/StackTraceFormatterTest.php b/tests/Issues/Formatters/StackTraceFormatterTest.php
new file mode 100644
index 0000000..d5b7bb0
--- /dev/null
+++ b/tests/Issues/Formatters/StackTraceFormatterTest.php
@@ -0,0 +1,56 @@
+formatter = new StackTraceFormatter;
+});
+
+test('it formats stack trace', function () {
+ $stackTrace = <<<'TRACE'
+#0 /app/Http/Controllers/TestController.php(25): TestController->testMethod()
+#1 /vendor/laravel/framework/src/Testing.php(50): VendorClass->vendorMethod()
+#2 /vendor/another/package/src/File.php(100): AnotherVendorClass->anotherVendorMethod()
+#3 /app/Services/TestService.php(30): TestService->serviceMethod()
+TRACE;
+
+ $formatted = $this->formatter->format($stackTrace);
+
+ expect($formatted)
+ ->toContain('/app/Http/Controllers/TestController.php')
+ ->toContain('/app/Services/TestService.php')
+ ->toContain('[Vendor frames]')
+ ->not->toContain('/vendor/laravel/framework/src/Testing.php')
+ ->not->toContain('/vendor/another/package/src/File.php');
+});
+
+test('it collapses consecutive vendor frames', function () {
+ $stackTrace = <<<'TRACE'
+#0 /vendor/package1/src/File1.php(10): Method1()
+#1 /vendor/package1/src/File2.php(20): Method2()
+#2 /vendor/package2/src/File3.php(30): Method3()
+TRACE;
+
+ $formatted = $this->formatter->format($stackTrace);
+
+ expect($formatted)
+ ->toContain('[Vendor frames]')
+ ->not->toContain('/vendor/package1/src/File1.php')
+ ->not->toContain('/vendor/package2/src/File3.php')
+ // Should only appear once even though there are multiple vendor frames
+ ->and(substr_count($formatted, '[Vendor frames]'))->toBe(1);
+});
+
+test('it preserves non-vendor frames', function () {
+ $stackTrace = <<<'TRACE'
+#0 /app/Http/Controllers/TestController.php(25): TestController->testMethod()
+#1 /app/Services/TestService.php(30): TestService->serviceMethod()
+TRACE;
+
+ $formatted = $this->formatter->format($stackTrace);
+
+ expect($formatted)
+ ->toContain('/app/Http/Controllers/TestController.php')
+ ->toContain('/app/Services/TestService.php')
+ ->not->toContain('[Vendor frames]');
+});
diff --git a/tests/Issues/HandlerTest.php b/tests/Issues/HandlerTest.php
index 339319d..4f92749 100644
--- a/tests/Issues/HandlerTest.php
+++ b/tests/Issues/HandlerTest.php
@@ -2,10 +2,12 @@
namespace Tests\Issues;
+use Illuminate\Http\Client\Request;
+use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Monolog\Level;
use Monolog\LogRecord;
-use Naoray\LaravelGithubMonolog\Issues\Formatter;
+use Naoray\LaravelGithubMonolog\Issues\Formatters\IssueFormatter;
use Naoray\LaravelGithubMonolog\Issues\Handler;
function createHandler(): Handler
@@ -18,7 +20,7 @@ function createHandler(): Handler
bubble: true
);
- $handler->setFormatter(new Formatter);
+ $handler->setFormatter(app()->make(IssueFormatter::class));
return $handler;
}
@@ -35,91 +37,118 @@ function createRecord(): LogRecord
);
}
-test('it creates new github issue when no duplicate exists', function () {
- $handler = createHandler();
- $record = createRecord();
+beforeEach(function () {
+ Http::preventStrayRequests();
+});
+test('it creates new github issue when no duplicate exists', function () {
Http::fake([
'github.com/search/issues*' => Http::response(['items' => []]),
'github.com/repos/test/repo/issues' => Http::response(['number' => 1]),
]);
+ $handler = createHandler();
+ $record = createRecord();
+
$handler->handle($record);
- Http::assertSent(function ($request) {
- return str_contains($request->url(), '/repos/test/repo/issues')
- && $request->method() === 'POST';
+ Http::assertSent(function (Request $request) {
+ return str($request->url())->endsWith('/repos/test/repo/issues');
});
});
test('it comments on existing github issue', function () {
- $handler = createHandler();
- $record = createRecord();
-
Http::fake([
'github.com/search/issues*' => Http::response(['items' => [['number' => 1]]]),
'github.com/repos/test/repo/issues/1/comments' => Http::response(['id' => 1]),
]);
+ $handler = createHandler();
+ $record = createRecord();
+
$handler->handle($record);
Http::assertSent(function ($request) {
- return str_contains($request->url(), '/issues/1/comments')
- && $request->method() === 'POST';
+ return str($request->url())->endsWith('/issues/1/comments');
});
});
test('it includes signature in issue search', function () {
- $handler = createHandler();
- $record = createRecord();
-
Http::fake([
'github.com/search/issues*' => Http::response(['items' => []]),
'github.com/repos/test/repo/issues' => Http::response(['number' => 1]),
]);
+ $handler = createHandler();
+ $record = createRecord();
+
$handler->handle($record);
Http::assertSent(function ($request) {
- return str_contains($request->url(), '/search/issues')
- && str_contains($request['q'], 'test-signature');
+ return str($request->url())->contains('/search/issues')
+ && str_contains($request->data()['q'], 'test-signature');
});
});
test('it throws exception when issue search fails', function () {
- $handler = createHandler();
- $record = createRecord();
-
Http::fake([
'github.com/search/issues*' => Http::response(['error' => 'Failed'], 500),
]);
- expect(fn () => $handler->handle($record))
- ->toThrow('Failed to search GitHub issues');
-});
-
-test('it throws exception when issue creation fails', function () {
$handler = createHandler();
$record = createRecord();
+ $handler->handle($record);
+})->throws(RequestException::class, exceptionCode: 500);
+
+test('it throws exception when issue creation fails', function () {
Http::fake([
'github.com/search/issues*' => Http::response(['items' => []]),
'github.com/repos/test/repo/issues' => Http::response(['error' => 'Failed'], 500),
]);
- expect(fn () => $handler->handle($record))
- ->toThrow('Failed to create GitHub issue');
-});
-
-test('it throws exception when comment creation fails', function () {
$handler = createHandler();
$record = createRecord();
+ $handler->handle($record);
+})->throws(RequestException::class, exceptionCode: 500);
+
+test('it throws exception when comment creation fails', function () {
Http::fake([
'github.com/search/issues*' => Http::response(['items' => [['number' => 1]]]),
'github.com/repos/test/repo/issues/1/comments' => Http::response(['error' => 'Failed'], 500),
]);
- expect(fn () => $handler->handle($record))
- ->toThrow('Failed to comment on GitHub issue');
+ $handler = createHandler();
+ $record = createRecord();
+
+ $handler->handle($record);
+})->throws(RequestException::class, exceptionCode: 500);
+
+test('it creates fallback issue when 4xx error occurs', function () {
+ $errorMessage = 'Validation failed for the issue';
+
+ Http::fake([
+ 'github.com/search/issues*' => Http::response(['items' => []]),
+ 'github.com/repos/test/repo/issues' => Http::sequence()
+ ->push(['error' => $errorMessage], 422)
+ ->push(['number' => 1]),
+ ]);
+
+ $handler = createHandler();
+ $record = createRecord();
+
+ $handler->handle($record);
+
+ Http::assertSent(function ($request) {
+ return str($request->url())->endsWith('/repos/test/repo/issues')
+ && ! str_contains($request->data()['title'], '[GitHub Monolog Error]');
+ });
+
+ Http::assertSent(function ($request) use ($errorMessage) {
+ return str($request->url())->endsWith('/repos/test/repo/issues')
+ && str_contains($request->data()['title'], '[GitHub Monolog Error]')
+ && str_contains($request->data()['body'], $errorMessage)
+ && in_array('monolog-integration-error', $request->data()['labels']);
+ });
});
diff --git a/tests/Issues/StubLoaderTest.php b/tests/Issues/StubLoaderTest.php
new file mode 100644
index 0000000..05cdb5f
--- /dev/null
+++ b/tests/Issues/StubLoaderTest.php
@@ -0,0 +1,85 @@
+loader = new StubLoader;
+ File::partialMock();
+});
+
+test('it loads stub from published path if it exists', function () {
+ $publishedPath = resource_path('views/vendor/github-monolog/issue.md');
+ File::shouldReceive('exists')
+ ->with($publishedPath)
+ ->andReturn(true);
+ File::shouldReceive('get')
+ ->with($publishedPath)
+ ->andReturn('published content');
+
+ expect($this->loader->load('issue'))->toBe('published content');
+});
+
+test('it falls back to package stub if published stub does not exist', function () {
+ $publishedPath = resource_path('views/vendor/github-monolog/issue.md');
+ $packagePath = __DIR__.'/../../resources/views/issue.md';
+ $expectedContent = <<<'MD'
+**Log Level:** {level}
+
+{message}
+
+**Simplified Stack Trace:**
+```php
+{simplified_stack_trace}
+```
+
+
+Complete Stack Trace
+
+```php
+{full_stack_trace}
+```
+
+
+
+Previous Exceptions
+
+{previous_exceptions}
+
+
+{context}
+
+{extra}
+
+
+
+MD;
+
+ File::shouldReceive('exists')
+ ->with($publishedPath)
+ ->andReturn(false);
+ File::shouldReceive('exists')
+ ->with($packagePath)
+ ->andReturn(true);
+ File::shouldReceive('get')
+ ->with($packagePath)
+ ->andReturn($expectedContent);
+
+ expect($this->loader->load('issue'))->toBe($expectedContent);
+});
+
+test('it throws exception if stub does not exist', function () {
+ $publishedPath = resource_path('views/vendor/github-monolog/nonexistent.md');
+ $packagePath = __DIR__.'/../../resources/views/nonexistent.md';
+
+ File::shouldReceive('exists')
+ ->with($publishedPath)
+ ->andReturn(false);
+ File::shouldReceive('exists')
+ ->with($packagePath)
+ ->andReturn(false);
+
+ expect(fn () => $this->loader->load('nonexistent'))
+ ->toThrow(FileNotFoundException::class);
+});
diff --git a/tests/Issues/TemplateRendererTest.php b/tests/Issues/TemplateRendererTest.php
new file mode 100644
index 0000000..008e5da
--- /dev/null
+++ b/tests/Issues/TemplateRendererTest.php
@@ -0,0 +1,142 @@
+stubLoader = Mockery::mock(StubLoader::class);
+ /** @var ExceptionFormatter&MockInterface */
+ $this->exceptionFormatter = Mockery::mock(ExceptionFormatter::class);
+
+ $this->stubLoader->shouldReceive('load')
+ ->with('issue')
+ ->andReturn('**Log Level:** {level}\n{message}\n{previous_exceptions}\n{context}\n{extra}\n{signature}');
+ $this->stubLoader->shouldReceive('load')
+ ->with('comment')
+ ->andReturn('# New Occurrence\n**Log Level:** {level}\n{message}');
+ $this->stubLoader->shouldReceive('load')
+ ->with('previous_exception')
+ ->andReturn('## Previous Exception #{count}\n{type}\n{simplified_stack_trace}');
+
+ $this->renderer = new TemplateRenderer(
+ exceptionFormatter: $this->exceptionFormatter,
+ stubLoader: $this->stubLoader,
+ );
+});
+
+test('it renders basic log record', function () {
+ $record = new LogRecord(
+ datetime: new DateTimeImmutable,
+ channel: 'test',
+ level: Level::Error,
+ message: 'Test message',
+ context: [],
+ extra: [],
+ );
+
+ $rendered = $this->renderer->render($this->stubLoader->load('issue'), $record);
+
+ expect($rendered)
+ ->toContain('**Log Level:** ERROR')
+ ->toContain('Test message');
+});
+
+test('it renders title without exception', function () {
+ $record = new LogRecord(
+ datetime: new DateTimeImmutable,
+ channel: 'test',
+ level: Level::Error,
+ message: 'Test message',
+ context: [],
+ extra: [],
+ );
+
+ $title = $this->renderer->renderTitle($record);
+
+ expect($title)->toBe('[ERROR] Test message');
+});
+
+test('it renders title with exception', function () {
+ $record = new LogRecord(
+ datetime: new DateTimeImmutable,
+ channel: 'test',
+ level: Level::Error,
+ message: 'Test message',
+ context: ['exception' => new RuntimeException('Test exception')],
+ extra: [],
+ );
+
+ $this->exceptionFormatter->shouldReceive('formatTitle')
+ ->once()
+ ->andReturn('[ERROR] RuntimeException: Test exception');
+
+ $title = $this->renderer->renderTitle($record, $record->context['exception']);
+
+ expect($title)->toBe('[ERROR] RuntimeException: Test exception');
+});
+
+test('it renders context data', function () {
+ $record = new LogRecord(
+ datetime: new DateTimeImmutable,
+ channel: 'test',
+ level: Level::Error,
+ message: 'Test message',
+ context: ['user_id' => 123],
+ extra: [],
+ );
+
+ $rendered = $this->renderer->render($this->stubLoader->load('issue'), $record);
+
+ expect($rendered)
+ ->toContain('**Context:**')
+ ->toContain('"user_id": 123');
+});
+
+test('it renders extra data', function () {
+ $record = new LogRecord(
+ datetime: new DateTimeImmutable,
+ channel: 'test',
+ level: Level::Error,
+ message: 'Test message',
+ context: [],
+ extra: ['request_id' => 'abc123'],
+ );
+
+ $rendered = $this->renderer->render($this->stubLoader->load('issue'), $record);
+
+ expect($rendered)
+ ->toContain('**Extra Data:**')
+ ->toContain('"request_id": "abc123"');
+});
+
+test('it renders previous exceptions', function () {
+ $previous = new RuntimeException('Previous exception');
+ $exception = new RuntimeException('Test exception', previous: $previous);
+ $record = new LogRecord(
+ datetime: new DateTimeImmutable,
+ channel: 'test',
+ level: Level::Error,
+ message: 'Test message',
+ context: ['exception' => $exception],
+ extra: [],
+ );
+
+ $this->exceptionFormatter->shouldReceive('format')
+ ->twice()
+ ->andReturn([
+ 'simplified_stack_trace' => 'simplified stack trace',
+ 'full_stack_trace' => 'full stack trace',
+ ]);
+
+ $rendered = $this->renderer->render($this->stubLoader->load('issue'), $record, null, $exception);
+
+ expect($rendered)
+ ->toContain('Previous Exception #1')
+ ->toContain(RuntimeException::class)
+ ->toContain('simplified stack trace');
+});
diff --git a/tests/Pest.php b/tests/Pest.php
index 71380e7..3c931bd 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -2,9 +2,6 @@
use Monolog\Level;
use Monolog\LogRecord;
-use Naoray\LaravelGithubMonolog\Deduplication\Stores\DatabaseStore;
-use Naoray\LaravelGithubMonolog\Deduplication\Stores\FileStore;
-use Naoray\LaravelGithubMonolog\Deduplication\Stores\RedisStore;
use Naoray\LaravelGithubMonolog\Tests\TestCase;
uses(TestCase::class)->in(__DIR__);
@@ -20,29 +17,3 @@ function createLogRecord(string $message = 'Test', array $context = [], Level $l
extra: [],
);
}
-
-function createDatabaseStore(int $time = 60): DatabaseStore
-{
- return new DatabaseStore(
- connection: 'sqlite',
- table: 'github_monolog_deduplication',
- time: $time
- );
-}
-
-function createFileStore(int $time = 60): FileStore
-{
- return new FileStore(
- path: test()->testPath,
- time: $time
- );
-}
-
-function createRedisStore(string $prefix = 'test:', int $time = 60): RedisStore
-{
- return new RedisStore(
- connection: 'default',
- prefix: $prefix,
- time: $time
- );
-}
diff --git a/tests/TestCase.php b/tests/TestCase.php
index 4d188d7..0071a29 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -2,9 +2,33 @@
namespace Naoray\LaravelGithubMonolog\Tests;
+use Naoray\LaravelGithubMonolog\GithubMonologServiceProvider;
use Orchestra\Testbench\TestCase as Orchestra;
class TestCase extends Orchestra
{
- //
+ protected function getPackageProviders($app): array
+ {
+ return [
+ GithubMonologServiceProvider::class,
+ ];
+ }
+
+ protected function defineEnvironment($app): void
+ {
+ // Configure the default cache store to array for testing
+ $app['config']->set('cache.default', 'array');
+ $app['config']->set('cache.stores.array', [
+ 'driver' => 'array',
+ 'serialize' => false,
+ ]);
+
+ // Configure database for testing
+ $app['config']->set('database.default', 'testing');
+ $app['config']->set('database.connections.testing', [
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ 'prefix' => '',
+ ]);
+ }
}