diff --git a/src/Concerns/ReadsLogs.php b/src/Concerns/ReadsLogs.php index 0b607ad1..cf3dee42 100644 --- a/src/Concerns/ReadsLogs.php +++ b/src/Concerns/ReadsLogs.php @@ -4,6 +4,7 @@ namespace Laravel\Boost\Concerns; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Config; trait ReadsLogs @@ -45,11 +46,73 @@ protected function resolveLogFilePath(): string $channel = Config::get('logging.default'); $channelConfig = Config::get("logging.channels.{$channel}"); - if (($channelConfig['driver'] ?? null) === 'daily') { - return storage_path('logs/laravel-'.date('Y-m-d').'.log'); + $channelConfig = $this->resolveChannelWithPath($channelConfig); + + $baseLogPath = Arr::get($channelConfig, 'path', storage_path('logs/laravel.log')); + + if (Arr::get($channelConfig, 'driver') === 'daily') { + return $this->resolveDailyLogFilePath($baseLogPath); + } + + return $baseLogPath; + } + + /*** + * @param array|null $channelConfig + * @return array|null + */ + protected function resolveChannelWithPath(?array $channelConfig, int $depth = 0): ?array + { + if ($channelConfig === null || $depth > 5) { + return $channelConfig; + } + + if (($channelConfig['driver'] ?? null) !== 'stack') { + return $channelConfig; } - return storage_path('logs/laravel.log'); + $stackChannels = $channelConfig['channels'] ?? []; + + foreach ($stackChannels as $stackChannel) { + $stackChannelConfig = Config::get("logging.channels.{$stackChannel}"); + + if (! is_array($stackChannelConfig)) { + continue; + } + + $resolved = $this->resolveChannelWithPath($stackChannelConfig, $depth + 1); + + if (isset($resolved['path'])) { + return $resolved; + } + } + + return $channelConfig; + } + + protected function resolveDailyLogFilePath(string $basePath): string + { + $pathInfo = pathinfo($basePath); + $directory = $pathInfo['dirname']; + $filename = $pathInfo['filename']; + $extension = isset($pathInfo['extension']) ? '.'.$pathInfo['extension'] : ''; + + $todayLogFile = $directory.DIRECTORY_SEPARATOR.$filename.'-'.date('Y-m-d').$extension; + + if (file_exists($todayLogFile)) { + return $todayLogFile; + } + + $pattern = $directory.DIRECTORY_SEPARATOR.$filename.'-*'.$extension; + $files = glob($pattern) ?: []; + + $datePattern = '/^'.preg_quote($filename, '/').'-\d{4}-\d{2}-\d{2}'.preg_quote($extension, '/').'$/'; + $latestFile = collect($files) + ->filter(fn ($file): int|false => preg_match($datePattern, basename((string) $file))) + ->sortDesc() + ->first(); + + return $latestFile ?? $todayLogFile; } /** @@ -130,7 +193,7 @@ protected function scanLogChunkForEntries(string $logFile, int $chunkSize): arra $offset = max($fileSize - $chunkSize, 0); fseek($handle, $offset); - // If we started mid-line, discard the partial line to align to next newline. + // If we started mid-line, discard the partial line to align to the next newline. if ($offset > 0) { fgets($handle); } diff --git a/tests/Feature/Mcp/Tools/ReadLogEntriesTest.php b/tests/Feature/Mcp/Tools/ReadLogEntriesTest.php new file mode 100644 index 00000000..fc6d5533 --- /dev/null +++ b/tests/Feature/Mcp/Tools/ReadLogEntriesTest.php @@ -0,0 +1,265 @@ + 'single', + 'path' => $logFile, + ]); + + File::ensureDirectoryExists(dirname($logFile)); + + $logContent = <<<'LOG' +[2024-01-15 10:00:00] local.DEBUG: First log message +[2024-01-15 10:01:00] local.ERROR: Error occurred +[2024-01-15 10:02:00] local.WARNING: Warning message +LOG; + + File::put($logFile, $logContent); + + $tool = new ReadLogEntries; + $response = $tool->handle(new Request(['entries' => 2])); + + expect($response)->isToolResult() + ->toolHasNoError() + ->toolTextContains('local.WARNING: Warning message', 'local.ERROR: Error occurred') + ->toolTextDoesNotContain('local.DEBUG: First log message'); +}); + +test('it detects daily driver directly and reads configured path', function (): void { + $basePath = storage_path('logs/laravel.log'); + $logFile = storage_path('logs/laravel-'.date('Y-m-d').'.log'); + + Config::set('logging.default', 'daily'); + Config::set('logging.channels.daily', [ + 'driver' => 'daily', + 'path' => $basePath, + ]); + + File::ensureDirectoryExists(dirname($logFile)); + + $logContent = <<<'LOG' +[2024-01-15 10:00:00] local.DEBUG: Daily log message +LOG; + + File::put($logFile, $logContent); + + $tool = new ReadLogEntries; + $response = $tool->handle(new Request(['entries' => 1])); + + expect($response)->isToolResult() + ->toolHasNoError() + ->toolTextContains('local.DEBUG: Daily log message'); +}); + +test('it detects daily driver within stack channel', function (): void { + $basePath = storage_path('logs/laravel.log'); + $logFile = storage_path('logs/laravel-'.date('Y-m-d').'.log'); + + Config::set('logging.default', 'stack'); + Config::set('logging.channels.stack', [ + 'driver' => 'stack', + 'channels' => ['daily'], + ]); + Config::set('logging.channels.daily', [ + 'driver' => 'daily', + 'path' => $basePath, + ]); + + File::ensureDirectoryExists(dirname($logFile)); + + $logContent = <<<'LOG' +[2024-01-15 10:00:00] local.DEBUG: Stack with daily log message +LOG; + + File::put($logFile, $logContent); + + $tool = new ReadLogEntries; + $response = $tool->handle(new Request(['entries' => 1])); + + expect($response)->isToolResult() + ->toolHasNoError() + ->toolTextContains('local.DEBUG: Stack with daily log message'); +}); + +test('it uses custom path from daily channel config', function (): void { + $basePath = storage_path('logs/custom-app.log'); + $logFile = storage_path('logs/custom-app-'.date('Y-m-d').'.log'); + + Config::set('logging.default', 'daily'); + Config::set('logging.channels.daily', [ + 'driver' => 'daily', + 'path' => $basePath, + ]); + + File::ensureDirectoryExists(dirname($logFile)); + + $logContent = <<<'LOG' +[2024-01-15 10:00:00] local.DEBUG: Custom path log message +LOG; + + File::put($logFile, $logContent); + + $tool = new ReadLogEntries; + $response = $tool->handle(new Request(['entries' => 1])); + + expect($response)->isToolResult() + ->toolHasNoError() + ->toolTextContains('local.DEBUG: Custom path log message'); +}); + +test('it falls back to most recent daily log when today has no logs', function (): void { + $basePath = storage_path('logs/laravel.log'); + + Config::set('logging.default', 'daily'); + Config::set('logging.channels.daily', [ + 'driver' => 'daily', + 'path' => $basePath, + ]); + + $logDir = storage_path('logs'); + File::ensureDirectoryExists($logDir); + + // Create a log file for yesterday + $yesterdayLogFile = $logDir.'/laravel-'.date('Y-m-d', strtotime('-1 day')).'.log'; + + $logContent = <<<'LOG' +[2024-01-14 10:00:00] local.DEBUG: Yesterday's log message +LOG; + + File::put($yesterdayLogFile, $logContent); + + $tool = new ReadLogEntries; + $response = $tool->handle(new Request(['entries' => 1])); + + expect($response)->isToolResult() + ->toolHasNoError() + ->toolTextContains("local.DEBUG: Yesterday's log message"); +}); + +test('it uses single channel path from stack when no daily channel', function (): void { + $logFile = storage_path('logs/app.log'); + + Config::set('logging.default', 'stack'); + Config::set('logging.channels.stack', [ + 'driver' => 'stack', + 'channels' => ['single'], + ]); + Config::set('logging.channels.single', [ + 'driver' => 'single', + 'path' => $logFile, + ]); + + File::ensureDirectoryExists(dirname($logFile)); + + $logContent = <<<'LOG' +[2024-01-15 10:00:00] local.DEBUG: Single in stack log message +LOG; + + File::put($logFile, $logContent); + + $tool = new ReadLogEntries; + $response = $tool->handle(new Request(['entries' => 1])); + + expect($response)->isToolResult() + ->toolHasNoError() + ->toolTextContains('local.DEBUG: Single in stack log message'); +}); + +test('it returns error when entries argument is invalid', function (): void { + $tool = new ReadLogEntries; + + // Test with zero + $response = $tool->handle(new Request(['entries' => 0])); + expect($response)->isToolResult() + ->toolHasError() + ->toolTextContains('The "entries" argument must be greater than 0.'); + + // Test with negative + $response = $tool->handle(new Request(['entries' => -5])); + expect($response)->isToolResult() + ->toolHasError() + ->toolTextContains('The "entries" argument must be greater than 0.'); +}); + +test('it returns error when log file does not exist', function (): void { + Config::set('logging.default', 'single'); + Config::set('logging.channels.single', [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + ]); + + $tool = new ReadLogEntries; + $response = $tool->handle(new Request(['entries' => 10])); + + expect($response)->isToolResult() + ->toolHasError() + ->toolTextContains('Log file not found'); +}); + +test('it returns error when log file is empty', function (): void { + $logFile = storage_path('logs/laravel.log'); + + Config::set('logging.default', 'single'); + Config::set('logging.channels.single', [ + 'driver' => 'single', + 'path' => $logFile, + ]); + + File::ensureDirectoryExists(dirname($logFile)); + File::put($logFile, ''); + + $tool = new ReadLogEntries; + $response = $tool->handle(new Request(['entries' => 5])); + + expect($response)->isToolResult() + ->toolHasNoError() + ->toolTextContains('Unable to retrieve log entries, or no entries yet.'); +}); + +test('it ignores non-daily log files when selecting most recent daily log', function (): void { + $basePath = storage_path('logs/laravel.log'); + Config::set('logging.default', 'daily'); + Config::set('logging.channels.daily', [ + 'driver' => 'daily', + 'path' => $basePath, + ]); + + $logDir = storage_path('logs'); + File::ensureDirectoryExists($logDir); + + File::put($logDir.'/laravel-2024-01-10.log', '[2024-01-10 10:00:00] local.DEBUG: Daily log from 2024-01-10'); + File::put($logDir.'/laravel-2024-01-15.log', '[2024-01-15 10:00:00] local.DEBUG: Daily log from 2024-01-15'); + File::put($logDir.'/laravel-backup.log', '[2024-01-20 10:00:00] local.DEBUG: Backup log'); + File::put($logDir.'/laravel-error.log', '[2024-01-20 10:00:00] local.DEBUG: Error log'); + File::put($logDir.'/laravel-zzz.log', '[2024-01-20 10:00:00] local.DEBUG: Zzz log'); + + $tool = new ReadLogEntries; + $response = $tool->handle(new Request(['entries' => 1])); + + expect($response)->isToolResult() + ->toolHasNoError() + ->toolTextContains('Daily log from 2024-01-15') + ->toolTextDoesNotContain('Backup log') + ->toolTextDoesNotContain('Error log') + ->toolTextDoesNotContain('Zzz log'); +});