日志管理
日志是系统可观测性的三大支柱之一(日志、指标、链路追踪)。完善的日志体系能够帮助开发者快速定位问题、分析用户行为、满足审计要求。本节将介绍 PSR-3 Logger 标准、Monolog 实战配置、日志分级策略、结构化日志以及 ELK(Elasticsearch + Logstash + Kibana)集成方案。
基础概念
日志级别(PSR-3 标准)
php
<?php
declare(strict_types=1);
namespace Psr\Log;
/**
* PSR-3 日志级别(从低到高)
*/
interface LoggerInterface
{
public function emergency(string|\Stringable $message, array $context = []): void;
public function alert(string|\Stringable $message, array $context = []): void;
public function critical(string|\Stringable $message, array $context = []): void;
public function error(string|\Stringable $message, array $context = []): void;
public function warning(string|\Stringable $message, array $context = []): void;
public function notice(string|\Stringable $message, array $context = []): void;
public function info(string|\Stringable $message, array $context = []): void;
public function debug(string|\Stringable $message, array $context = []): void;
}| 级别 | 说明 | 使用场景 |
|---|---|---|
| DEBUG | 详细的调试信息 | 开发调试、变量值、执行路径 |
| INFO | 有意义的事件 | 用户登录、订单创建、任务完成 |
| NOTICE | 正常但值得注意的事件 | 旧 API 被调用、配置变更 |
| WARNING | 异常但非错误 | 降级服务、重试操作、接近配额 |
| ERROR | 运行时错误 | 异常捕获、数据库连接失败 |
| CRITICAL | 严重错误 | 系统组件不可用、数据损坏 |
| ALERT | 需要立即行动 | 数据丢失、安全漏洞 |
| EMERGENCY | 系统不可用 | 整个系统宕机 |
Monolog 配置
安装与基础使用
bash
composer require monolog/monolog基础配置
php
<?php
declare(strict_types=1);
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Handler\FingersCrossedHandler;
use Monolog\Handler\FilterHandler;
use Monolog\Level;
use Monolog\Processor\WebProcessor;
use Monolog\Processor\IntrospectionProcessor;
use Monolog\Processor\UidProcessor;
// 创建 Logger
$logger = new Logger('app');
// 添加 Handler
$logger->pushHandler(new StreamHandler(
'/var/log/app/app.log',
Level::Debug,
));
// 使用
$logger->info('User logged in', ['user_id' => 123, 'ip' => '192.168.1.1']);
$logger->warning('Rate limit approaching', ['user_id' => 123, 'remaining' => 10]);
$logger->error('Database connection failed', ['host' => 'localhost', 'error' => 'Connection refused']);生产环境配置
php
<?php
declare(strict_types=1);
use Monolog\Logger;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\FingersCrossedHandler;
use Monolog\Handler\FilterHandler;
use Monolog\Handler\BufferHandler;
use Monolog\Formatter\JsonFormatter;
use Monolog\Formatter\LineFormatter;
use Monolog\Processor\WebProcessor;
use Monolog\Processor\IntrospectionProcessor;
use Monolog\Processor\UidProcessor;
use Monolog\Processor\MemoryUsageProcessor;
use Monolog\Processor\MemoryPeakUsageProcessor;
use Monolog\Level;
class LoggerFactory
{
public static function create(string $channel, string $env = 'production'): Logger
{
$logger = new Logger($channel);
// 添加通用 Processor
$logger->pushProcessor(new UidProcessor());
$logger->pushProcessor(new WebProcessor());
$logger->pushProcessor(new MemoryUsageProcessor(memoryUsage: true, realUsage: true));
$logger->pushProcessor(new MemoryPeakUsageProcessor(memoryUsage: true, realUsage: true));
$handlers = [];
if ($env === 'production') {
$handlers = self::createProductionHandlers($channel);
} else {
$handlers = self::createDevelopmentHandlers($channel);
}
foreach ($handlers as $handler) {
$logger->pushHandler($handler);
}
return $logger;
}
private static function createProductionHandlers(string $channel): array
{
$handlers = [];
// 1. 所有日志写入 rotating file(JSON 格式)
$allHandler = new RotatingFileHandler(
"/var/log/app/{$channel}.log",
30, // 保留 30 天
Level::Info,
);
$allHandler->setFormatter(new JsonFormatter());
$handlers[] = $allHandler;
// 2. 错误及以上级别写入单独文件
$errorHandler = new FilterHandler(
new RotatingFileHandler(
"/var/log/app/{$channel}-error.log",
30,
Level::Error,
),
Level::Error,
Level::Emergency,
);
$errorHandler->setFormatter(new JsonFormatter());
$handlers[] = $errorHandler;
// 3. FingersCrossed 缓冲策略:正常情况不写日志,出现错误时把之前 INFO 也写入
$fingersCrossed = new FingersCrossedHandler(
new StreamHandler(
"/var/log/app/{$channel}-buffered.log",
Level::Debug,
),
Level::Error, // 触发阈值
bufferLimit: 100, // 缓冲条数
stopPropagation: false,
);
$fingersCrossed->setFormatter(new JsonFormatter());
$handlers[] = $fingersCrossed;
// 4. stderr 输出(Docker 环境)
$stderrHandler = new StreamHandler('php://stderr', Level::Warning);
$stderrHandler->setFormatter(new JsonFormatter());
$handlers[] = $stderrHandler;
return $handlers;
}
private static function createDevelopmentHandlers(string $channel): array
{
$formatter = new LineFormatter(
"[%datetime%] %channel%.%level_name%: %message% %context% %extra%\n",
'Y-m-d H:i:s',
);
$handler = new StreamHandler("php://stdout", Level::Debug);
$handler->setFormatter($formatter);
return [$handler];
}
}
// 使用
$logger = LoggerFactory::create('payment-service');
$logger->info('Payment processed', ['order_id' => 'ORD-123', 'amount' => 99.99]);结构化日志
JSON 格式日志
php
<?php
declare(strict_types=1);
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Formatter\JsonFormatter;
use Monolog\Level;
$logger = new Logger('structured');
$handler = new StreamHandler('/var/log/app/structured.log', Level::Debug);
$handler->setFormatter(new JsonFormatter());
$logger->pushHandler($handler);
$logger->info('Order created', [
'event' => 'order.created',
'order_id' => 'ORD-456',
'user_id' => 789,
'amount' => 149.99,
'currency' => 'CNY',
'items_count' => 3,
]);
// 输出的 JSON 格式:
// {
// "message": "Order created",
// "context": {
// "event": "order.created",
// "order_id": "ORD-456",
// "user_id": 789,
// "amount": 149.99,
// "currency": "CNY",
// "items_count": 3
// },
// "level": 200,
// "level_name": "INFO",
// "channel": "structured",
// "datetime": "2024-01-15T10:30:45.123456+08:00",
// "extra": {
// "uid": "5f8a2b3c",
// "memory_usage": "8.5 MB",
// "memory_peak_usage": "12.3 MB"
// }
// }自定义结构化 Processor
php
<?php
declare(strict_types=1);
namespace App\Logging;
use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
/**
* 添加应用上下文信息的 Processor
*/
class AppContextProcessor implements ProcessorInterface
{
public function __construct(
private readonly string $appName,
private readonly string $appEnv,
private readonly string $appVersion,
) {}
public function __invoke(LogRecord $record): LogRecord
{
$record->extra['app'] = $this->appName;
$record->extra['env'] = $this->appEnv;
$record->extra['version'] = $this->appVersion;
return $record;
}
}
/**
* 添加请求追踪 ID 的 Processor
*/
class TraceIdProcessor implements ProcessorInterface
{
public function __invoke(LogRecord $record): LogRecord
{
$traceId = $_SERVER['HTTP_X_TRACE_ID']
?? $_COOKIE['trace_id']
?? bin2hex(random_bytes(16));
$record->extra['trace_id'] = $traceId;
return $record;
}
}
/**
* 敏感信息脱敏 Processor
*/
class SensitiveDataProcessor implements ProcessorInterface
{
/** @var array<int, string> */
private array $sensitiveKeys = [
'password', 'token', 'secret', 'api_key', 'credit_card',
'ssn', 'authorization', 'cookie',
];
public function __invoke(LogRecord $record): LogRecord
{
$record->context = $this->filterSensitiveData($record->context);
return $record;
}
private function filterSensitiveData(array $data): array
{
foreach ($data as $key => $value) {
if (is_array($value)) {
$data[$key] = $this->filterSensitiveData($value);
} elseif ($this->isSensitive($key)) {
$data[$key] = '[REDACTED]';
}
}
return $data;
}
private function isSensitive(string $key): bool
{
$lowerKey = strtolower($key);
foreach ($this->sensitiveKeys as $sensitive) {
if (str_contains($lowerKey, $sensitive)) {
return true;
}
}
return false;
}
}ELK 集成
Logstash 配置
conf
# /etc/logstash/conf.d/php-app.conf
input {
file {
path => "/var/log/app/*.log"
start_position => "beginning"
sincedb_path => "/var/logstash/sincedb"
codec => json
}
}
filter {
# 解析日志级别
if [level_name] {
mutate {
add_field => { "log_level" => "%{level_name}" }
}
}
# 解析时间戳
date {
match => ["datetime", "ISO8601"]
target => "@timestamp"
}
# 提取事件类型
if [context][event] {
mutate {
add_field => { "event_type" => "%{[context][event]}" }
}
}
# 移除不需要的字段
mutate {
remove_field => ["datetime", "channel", "level"]
}
}
output {
elasticsearch {
hosts => ["http://localhost:9200"]
index => "php-app-%{+YYYY.MM.dd}"
template => "/etc/logstash/templates/php-app.json"
template_name => "php-app"
}
}Elasticsearch 索引模板
json
{
"template": "php-app-*",
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0,
"index.lifecycle.name": "php-app-policy",
"index.lifecycle.rollover_alias": "php-app"
},
"mappings": {
"properties": {
"@timestamp": { "type": "date" },
"message": { "type": "text" },
"level_name": { "type": "keyword" },
"app": { "type": "keyword" },
"env": { "type": "keyword" },
"trace_id": { "type": "keyword" },
"event_type": { "type": "keyword" },
"context": {
"type": "object",
"dynamic": true
},
"extra": {
"type": "object",
"dynamic": true
}
}
}
}实战示例
Laravel 日志配置
php
<?php
// config/logging.php
return [
'default' => env('LOG_CHANNEL', 'stack'),
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', env('LOG_STACK', 'daily,stderr')),
'ignore_exceptions' => false,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/app.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 30,
],
'stderr' => [
'driver' => 'monolog',
'handler' => StreamHandler::class,
'formatter' => env('LOG_FORMAT', 'default'),
'with' => [
'stream' => 'php://stderr',
],
],
'json' => [
'driver' => 'daily',
'path' => storage_path('logs/app-json.log'),
'level' => 'debug',
'days' => 14,
'formatter' => Monolog\Formatter\JsonFormatter::class,
],
'elastic' => [
'driver' => 'monolog',
'handler' => Monolog\Handler\ElasticsearchHandler::class,
'formatter' => Monolog\Formatter\JsonFormatter::class,
'with' => [
'hosts' => [
env('ELASTICSEARCH_HOST', 'http://localhost:9200'),
],
'index' => 'php-app',
],
],
],
];注意事项
日志安全
- 生产环境日志级别至少为 INFO,避免泄露敏感信息
- 使用 SensitiveDataProcessor 脱敏密码、Token 等敏感数据
- 日志文件权限设置为 640,仅应用和日志系统可读
- 不要在日志中记录完整的用户密码或 API Key
最佳实践
- 结构化日志:使用 JSON 格式,便于日志收集和分析
- 统一日志格式:包含时间戳、级别、来源、消息、上下文
- 日志分级:合理使用不同级别,生产环境过滤 DEBUG
- 敏感信息脱敏:自动过滤密码、Token 等敏感字段
- 日志轮转:避免日志文件无限增长
- 集中管理:使用 ELK 或云服务集中管理日志
- 上下文信息:记录请求 ID、用户 ID 等便于追踪的信息
下一节
继续学习:监控与告警