Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 67 additions & 4 deletions src/Concerns/ReadsLogs.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Laravel\Boost\Concerns;

use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;

trait ReadsLogs
Expand Down Expand Up @@ -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<string, mixed>|null $channelConfig
* @return array<string, mixed>|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;
}

/**
Expand Down Expand Up @@ -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);
}
Expand Down
265 changes: 265 additions & 0 deletions tests/Feature/Mcp/Tools/ReadLogEntriesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
use Laravel\Boost\Mcp\Tools\ReadLogEntries;
use Laravel\Mcp\Request;

beforeEach(function (): void {
$logDir = storage_path('logs');
$files = glob($logDir.'/*.log');
if ($files) {
foreach ($files as $file) {
File::delete($file);
}
}
});

test('it returns log entries when file exists with single driver', 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));

$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');
});