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
76 changes: 74 additions & 2 deletions src/Concerns/ReadsLogs.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,83 @@ protected function resolveLogFilePath(): string
$channel = Config::get('logging.default');
$channelConfig = Config::get("logging.channels.{$channel}");

// Handle stack driver by resolving to its first channel with a path
$channelConfig = $this->resolveChannelWithPath($channelConfig);

if (($channelConfig['driver'] ?? null) === 'daily') {
return storage_path('logs/laravel-'.date('Y-m-d').'.log');
return $this->resolveDailyLogFilePath($channelConfig['path'] ?? storage_path('logs/laravel.log'));
}

return $channelConfig['path'] ?? storage_path('logs/laravel.log');
}

/**
* Resolve a channel config that has a path, handling stack drivers recursively.
*
* @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;
}

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

/**
* Resolve the daily log file path, falling back to the most recent if today's doesn't exist.
*
* @param string $basePath The configured path (e.g., storage_path('logs/laravel.log'))
*/
protected function resolveDailyLogFilePath(string $basePath): string
{
// Daily driver appends date before the extension: laravel.log -> laravel-2025-12-14.log
$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;
}

// Look for the most recent daily log file with matching base name
$pattern = $directory.DIRECTORY_SEPARATOR.$filename.'-*'.$extension;
$files = glob($pattern);

if ($files !== false && $files !== []) {
// Sort by filename (which includes date) in descending order to get most recent
rsort($files);

return $files[0];
}

return storage_path('logs/laravel.log');
// Fall back to today's path even if it doesn't exist (error will be handled by caller)
return $todayLogFile;
}

/**
Expand Down
238 changes: 238 additions & 0 deletions tests/Feature/Mcp/Tools/ReadLogEntriesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
<?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 {
// Clean up any existing log files before each test
$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.');
});