Laravel 13 提供了强大的日志系统,本文介绍如何进行日志分析和构建日志监控体系。

日志配置

多通道配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<?php

return [
'default' => env('LOG_CHANNEL', 'stack'),

'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['daily', 'slack'],
'ignore_exceptions' => false,
],

'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 14,
'permission' => 0644,
],

'errors' => [
'driver' => 'daily',
'path' => storage_path('logs/errors.log'),
'level' => 'error',
'days' => 30,
],

'requests' => [
'driver' => 'daily',
'path' => storage_path('logs/requests.log'),
'level' => 'info',
'days' => 7,
],

'performance' => [
'driver' => 'daily',
'path' => storage_path('logs/performance.log'),
'level' => 'info',
'days' => 7,
],

'security' => [
'driver' => 'daily',
'path' => storage_path('logs/security.log'),
'level' => 'warning',
'days' => 90,
],

'json' => [
'driver' => 'daily',
'path' => storage_path('logs/structured.json'),
'level' => 'debug',
'days' => 14,
'formatter' => Monolog\Formatter\JsonFormatter::class,
],
],
];

结构化日志

自定义日志格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?php

namespace App\Logging;

use Monolog\Formatter\FormatterInterface;

class StructuredFormatter implements FormatterInterface
{
public function format(array $record): string
{
$data = [
'timestamp' => $record['datetime']->format('Y-m-d H:i:s.u'),
'level' => $record['level_name'],
'message' => $record['message'],
'context' => $record['context'] ?? [],
'extra' => $record['extra'] ?? [],
'request_id' => request()->header('X-Request-ID'),
'user_id' => auth()->id(),
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'url' => request()->fullUrl(),
'method' => request()->method(),
];

return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
}

public function formatBatch(array $records): string
{
$output = '';
foreach ($records as $record) {
$output .= $this->format($record);
}
return $output;
}
}

日志上下文增强

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<?php

namespace App\Logging;

use Illuminate\Log\Logger;
use Monolog\Processor\ProcessorInterface;

class ContextProcessor implements ProcessorInterface
{
public function __invoke(array $record): array
{
$record['extra']['request_id'] = request()->header('X-Request-ID');
$record['extra']['correlation_id'] = app('correlation.id');
$record['extra']['user_id'] = auth()->id();
$record['extra']['session_id'] = session()->getId();
$record['extra']['memory_usage'] = memory_get_usage(true);
$record['extra']['peak_memory'] = memory_get_peak_usage(true);

return $record;
}
}

class ContextLogger
{
protected array $context = [];

public function withContext(array $context): self
{
$this->context = array_merge($this->context, $context);
return $this;
}

public function withUser(int $userId): self
{
return $this->withContext(['user_id' => $userId]);
}

public function withRequest(): self
{
return $this->withContext([
'request_id' => request()->header('X-Request-ID'),
'ip' => request()->ip(),
'url' => request()->fullUrl(),
]);
}

public function info(string $message, array $context = []): void
{
Log::channel('structured')->info($message, array_merge($this->context, $context));
}

public function error(string $message, array $context = []): void
{
Log::channel('structured')->error($message, array_merge($this->context, $context));
}
}

日志分析器

基础日志解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
<?php

namespace App\Services\Logging;

use Illuminate\Support\Collection;

class LogParser
{
protected string $logPath;

public function __construct(string $logPath = null)
{
$this->logPath = $logPath ?? storage_path('logs');
}

public function parseFile(string $filename): Collection
{
$filepath = $this->logPath . '/' . $filename;

if (!file_exists($filepath)) {
return collect();
}

$content = file_get_contents($filepath);
$lines = explode("\n", $content);

return collect($lines)
->filter()
->map(fn($line) => $this->parseLine($line))
->filter();
}

protected function parseLine(string $line): ?array
{
$pattern = '/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] (\w+)\.(\w+): (.*)$/';

if (!preg_match($pattern, $line, $matches)) {
return null;
}

return [
'timestamp' => $matches[1],
'channel' => $matches[2],
'level' => $matches[3],
'message' => $matches[4],
];
}

public function parseJsonFile(string $filename): Collection
{
$filepath = $this->logPath . '/' . $filename;

if (!file_exists($filepath)) {
return collect();
}

$content = file_get_contents($filepath);
$lines = explode("\n", $content);

return collect($lines)
->filter()
->map(function ($line) {
$data = json_decode($line, true);
return $data ? $this->normalizeJsonLog($data) : null;
})
->filter();
}

protected function normalizeJsonLog(array $data): array
{
return [
'timestamp' => $data['timestamp'] ?? $data['datetime'] ?? null,
'level' => $data['level'] ?? $data['level_name'] ?? 'INFO',
'message' => $data['message'] ?? '',
'context' => $data['context'] ?? [],
'extra' => $data['extra'] ?? [],
];
}
}

日志统计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<?php

namespace App\Services\Logging;

use Illuminate\Support\Collection;

class LogStatistics
{
public function __construct(
protected LogParser $parser
) {}

public function getLevelDistribution(Collection $logs): array
{
return $logs->groupBy('level')
->map(fn($group) => $group->count())
->sortDesc()
->toArray();
}

public function getHourlyDistribution(Collection $logs): array
{
return $logs->groupBy(function ($log) {
return substr($log['timestamp'], 11, 2);
})
->map(fn($group) => $group->count())
->sortKeys()
->toArray();
}

public function getTopMessages(Collection $logs, int $limit = 10): array
{
return $logs->groupBy('message')
->map(fn($group) => [
'count' => $group->count(),
'level' => $group->first()['level'],
])
->sortByDesc('count')
->take($limit)
->toArray();
}

public function getErrorSummary(Collection $logs): array
{
$errors = $logs->whereIn('level', ['ERROR', 'CRITICAL', 'ALERT', 'EMERGENCY']);

return [
'total_errors' => $errors->count(),
'unique_errors' => $errors->unique('message')->count(),
'by_level' => $errors->groupBy('level')
->map(fn($group) => $group->count())
->toArray(),
'recent_errors' => $errors->take(10)->values()->toArray(),
];
}

public function getSlowRequests(Collection $logs, float $threshold = 1.0): array
{
return $logs->filter(function ($log) use ($threshold) {
$duration = $log['context']['duration'] ?? $log['extra']['duration'] ?? 0;
return $duration > $threshold;
})
->sortByDesc(function ($log) {
return $log['context']['duration'] ?? $log['extra']['duration'] ?? 0;
})
->values()
->toArray();
}
}

日志搜索

日志搜索器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
<?php

namespace App\Services\Logging;

use Illuminate\Support\Collection;

class LogSearcher
{
protected Collection $logs;

public function __construct(Collection $logs)
{
$this->logs = $logs;
}

public function byLevel(string $level): self
{
$this->logs = $this->logs->where('level', strtoupper($level));
return $this;
}

public function byLevels(array $levels): self
{
$levels = array_map('strtoupper', $levels);
$this->logs = $this->logs->whereIn('level', $levels);
return $this;
}

public function byMessage(string $pattern): self
{
$this->logs = $this->logs->filter(function ($log) use ($pattern) {
return str_contains($log['message'], $pattern) ||
preg_match($pattern, $log['message']);
});
return $this;
}

public function byTimeRange(string $start, string $end): self
{
$this->logs = $this->logs->filter(function ($log) use ($start, $end) {
$timestamp = $log['timestamp'];
return $timestamp >= $start && $timestamp <= $end;
});
return $this;
}

public function byUserId(int $userId): self
{
$this->logs = $this->logs->filter(function ($log) use ($userId) {
return ($log['context']['user_id'] ?? $log['extra']['user_id'] ?? null) === $userId;
});
return $this;
}

public function byRequestId(string $requestId): self
{
$this->logs = $this->logs->filter(function ($log) use ($requestId) {
return ($log['context']['request_id'] ?? $log['extra']['request_id'] ?? null) === $requestId;
});
return $this;
}

public function byIp(string $ip): self
{
$this->logs = $this->logs->filter(function ($log) use ($ip) {
return ($log['context']['ip'] ?? $log['extra']['ip'] ?? null) === $ip;
});
return $this;
}

public function get(): Collection
{
return $this->logs->values();
}

public function first(): ?array
{
return $this->logs->first();
}

public function count(): int
{
return $this->logs->count();
}
}

日志监控

实时日志监控

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<?php

namespace App\Services\Logging;

use Illuminate\Support\Facades\File;

class LogMonitor
{
protected array $watchers = [];
protected array $filePositions = [];

public function watchFile(string $filepath, callable $callback): void
{
$this->watchers[$filepath] = $callback;
$this->filePositions[$filepath] = 0;
}

public function check(): void
{
foreach ($this->watchers as $filepath => $callback) {
$this->checkFile($filepath, $callback);
}
}

protected function checkFile(string $filepath, callable $callback): void
{
if (!File::exists($filepath)) {
return;
}

$currentSize = File::size($filepath);
$lastPosition = $this->filePositions[$filepath] ?? 0;

if ($currentSize <= $lastPosition) {
if ($currentSize < $lastPosition) {
$this->filePositions[$filepath] = 0;
}
return;
}

$handle = fopen($filepath, 'r');
fseek($handle, $lastPosition);

while (($line = fgets($handle)) !== false) {
$parsed = $this->parseLine($line);
if ($parsed) {
$callback($parsed);
}
}

$this->filePositions[$filepath] = ftell($handle);
fclose($handle);
}

protected function parseLine(string $line): ?array
{
if (str_starts_with($line, '{')) {
return json_decode($line, true);
}

$pattern = '/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] (\w+)\.(\w+): (.*)$/';

if (preg_match($pattern, $line, $matches)) {
return [
'timestamp' => $matches[1],
'channel' => $matches[2],
'level' => $matches[3],
'message' => $matches[4],
];
}

return null;
}
}

日志告警

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
<?php

namespace App\Services\Logging;

use App\Notifications\LogAlertNotification;
use Illuminate\Support\Facades\Notification;

class LogAlerter
{
protected array $rules = [];
protected int $cooldown = 300;
protected array $lastAlerts = [];

public function addRule(string $name, callable $condition, array $config = []): self
{
$this->rules[$name] = array_merge([
'condition' => $condition,
'threshold' => 1,
'window' => 60,
'channels' => ['mail'],
], $config);

return $this;
}

public function check(array $log): void
{
foreach ($this->rules as $name => $rule) {
if ($rule['condition']($log)) {
$this->triggerAlert($name, $log, $rule);
}
}
}

protected function triggerAlert(string $name, array $log, array $rule): void
{
$key = $name . ':' . ($log['extra']['request_id'] ?? md5($log['message']));

if (isset($this->lastAlerts[$key])) {
if (now()->diffInSeconds($this->lastAlerts[$key]) < $this->cooldown) {
return;
}
}

$this->lastAlerts[$key] = now();

$alert = new LogAlert(
name: $name,
log: $log,
timestamp: now(),
severity: $this->determineSeverity($log)
);

$this->sendAlert($alert, $rule['channels']);
}

protected function sendAlert(LogAlert $alert, array $channels): void
{
foreach ($channels as $channel) {
match ($channel) {
'mail' => $this->sendMailAlert($alert),
'slack' => $this->sendSlackAlert($alert),
'webhook' => $this->sendWebhookAlert($alert),
default => null,
};
}
}

protected function sendMailAlert(LogAlert $alert): void
{
$recipients = config('logging.alert_emails', ['admin@example.com']);

Notification::route('mail', $recipients)
->notify(new LogAlertNotification($alert));
}

protected function sendSlackAlert(LogAlert $alert): void
{
Notification::route('slack', config('logging.slack_webhook'))
->notify(new LogAlertNotification($alert));
}

protected function sendWebhookAlert(LogAlert $alert): void
{
$webhook = config('logging.alert_webhook');

if ($webhook) {
Http::post($webhook, $alert->toArray());
}
}

protected function determineSeverity(array $log): string
{
return match (strtoupper($log['level'] ?? 'INFO')) {
'EMERGENCY', 'ALERT', 'CRITICAL' => 'critical',
'ERROR' => 'high',
'WARNING' => 'medium',
default => 'low',
};
}
}

日志分析命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
<?php

namespace App\Console\Commands;

use App\Services\Logging\LogParser;
use App\Services\Logging\LogStatistics;
use Illuminate\Console\Command;

class LogAnalyzeCommand extends Command
{
protected $signature = 'log:analyze
{file? : Log file to analyze}
{--level= : Filter by log level}
{--hours=24 : Hours to analyze}
{--format=table : Output format}';
protected $description = 'Analyze log files';

public function handle(LogParser $parser, LogStatistics $stats): int
{
$file = $this->argument('file') ?? $this->getLatestLogFile();

$this->info("Analyzing: {$file}");

$logs = $parser->parseJsonFile($file);

if ($level = $this->option('level')) {
$logs = $logs->where('level', strtoupper($level));
}

$hours = (int) $this->option('hours');
$cutoff = now()->subHours($hours)->format('Y-m-d H:i:s');
$logs = $logs->filter(fn($log) => $log['timestamp'] >= $cutoff);

$this->displayResults($logs, $stats);

return self::SUCCESS;
}

protected function getLatestLogFile(): string
{
$files = glob(storage_path('logs/structured-*.json'));

if (empty($files)) {
return 'laravel.log';
}

usort($files, fn($a, $b) => filemtime($b) - filemtime($a));

return basename($files[0]);
}

protected function displayResults($logs, $stats): void
{
$this->info('=== Log Analysis Report ===');
$this->newLine();

$this->info("Total entries: {$logs->count()}");
$this->newLine();

$this->info('Level Distribution:');
$this->table(
['Level', 'Count'],
collect($stats->getLevelDistribution($logs))
->map(fn($count, $level) => [$level, $count])
);

$this->newLine();

$this->info('Error Summary:');
$errorSummary = $stats->getErrorSummary($logs);
$this->table(
['Metric', 'Value'],
[
['Total Errors', $errorSummary['total_errors']],
['Unique Errors', $errorSummary['unique_errors']],
]
);

if (!empty($errorSummary['recent_errors'])) {
$this->newLine();
$this->info('Recent Errors:');
foreach (array_slice($errorSummary['recent_errors'], 0, 5) as $error) {
$this->line("- [{$error['timestamp']}] {$error['message']}");
}
}

$this->newLine();

$this->info('Hourly Distribution:');
$hourly = $stats->getHourlyDistribution($logs);
$this->displaySparkline($hourly);
}

protected function displaySparkline(array $data): void
{
$max = max($data);
$bars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];

$sparkline = collect(range(0, 23))
->map(function ($hour) use ($data, $max, $bars) {
$value = $data[str_pad($hour, 2, '0', STR_PAD_LEFT)] ?? 0;
$index = $max > 0 ? (int) (($value / $max) * 7) : 0;
return $bars[$index];
})
->join('');

$this->line($sparkline);
}
}

日志清理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
<?php

namespace App\Services\Logging;

use Illuminate\Support\Facades\File;

class LogCleaner
{
protected string $logPath;

public function __construct(string $logPath = null)
{
$this->logPath = $logPath ?? storage_path('logs');
}

public function cleanOldLogs(int $daysToKeep = 30): array
{
$files = File::glob($this->logPath . '/*.log');
$cutoff = now()->subDays($daysToKeep);
$deleted = [];

foreach ($files as $file) {
$mtime = File::lastModified($file);

if ($mtime < $cutoff->timestamp) {
File::delete($file);
$deleted[] = basename($file);
}
}

return $deleted;
}

public function archiveOldLogs(int $daysToKeep = 7): array
{
$files = File::glob($this->logPath . '/*.log');
$cutoff = now()->subDays($daysToKeep);
$archived = [];

foreach ($files as $file) {
$mtime = File::lastModified($file);

if ($mtime < $cutoff->timestamp) {
$archivePath = $this->logPath . '/archive/' . date('Y-m', $mtime);

if (!File::isDirectory($archivePath)) {
File::makeDirectory($archivePath, 0755, true);
}

$newPath = $archivePath . '/' . basename($file);
File::move($file, $newPath);
$archived[] = basename($file);
}
}

return $archived;
}

public function compressArchive(): array
{
$archivePath = $this->logPath . '/archive';

if (!File::isDirectory($archivePath)) {
return [];
}

$months = File::directories($archivePath);
$compressed = [];

foreach ($months as $monthPath) {
$zipFile = $monthPath . '.zip';

if (File::exists($zipFile)) {
continue;
}

$zip = new \ZipArchive();
$zip->open($zipFile, \ZipArchive::CREATE);

$files = File::files($monthPath);
foreach ($files as $file) {
$zip->addFile($file->getPathname(), $file->getBasename());
}

$zip->close();

File::deleteDirectory($monthPath);
$compressed[] = basename($monthPath);
}

return $compressed;
}
}

总结

Laravel 13 的日志分析系统需要结合结构化日志、日志解析、统计分析和实时监控来构建。通过完善的日志体系,可以快速定位问题、监控系统健康状态,并为性能优化提供数据支持。