Skip to content

日志管理

日志是系统可观测性的三大支柱之一(日志、指标、链路追踪)。完善的日志体系能够帮助开发者快速定位问题、分析用户行为、满足审计要求。本节将介绍 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

最佳实践

  1. 结构化日志:使用 JSON 格式,便于日志收集和分析
  2. 统一日志格式:包含时间戳、级别、来源、消息、上下文
  3. 日志分级:合理使用不同级别,生产环境过滤 DEBUG
  4. 敏感信息脱敏:自动过滤密码、Token 等敏感字段
  5. 日志轮转:避免日志文件无限增长
  6. 集中管理:使用 ELK 或云服务集中管理日志
  7. 上下文信息:记录请求 ID、用户 ID 等便于追踪的信息

下一节

继续学习:监控与告警

参考链接