Активность пользователей PHP: подходы к хранению логов и работа с ошибками
Введение в логирование активности пользователей
Логирование активности пользователей позволяет отслеживать действия, совершаемые на сайте: вход в систему, просмотр страниц, выполнение операций. Эта информация полезна для аудита, анализа поведения и выявления ошибок. В PHP существует множество способов реализации логирования, каждый со своими достоинствами и недостатками. В этой статье рассмотрены основные подходы, от простых файловых логов до асинхронных систем, а также вопросы обработки ошибок при логировании.
Основное решение: использование библиотеки Monolog
Monolog - это стандартная библиотека для логирования в PHP, предоставляющая гибкую систему каналов и обработчиков. Она позволяет легко переключаться между различными хранилищами (файлы, база данных, системный журнал) и поддерживает форматирование сообщений.
Как реализовать универсальное логирование активности с возможностью расширения?
Пример подключения и настройки Monolog через Composer:
composer require monolog/monolog
Код инициализации логгера:
<?php
require 'vendor/autoload.php';
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Formatter\LineFormatter;
// Создаем канал 'activity'
$logger = new Logger('activity');
// Обработчик для записи в файл
$handler = new StreamHandler(__DIR__ . '/logs/activity.log', Logger::INFO);
$formatter = new LineFormatter("[%datetime%] %channel%.%level_name%: %message% %context%\n");
$handler->setFormatter($formatter);
$logger->pushHandler($handler);
// Логируем действие
$logger->info('Пользователь вошел в систему', ['user_id' => 123, 'ip' => '192.168.1.1']);
?>
Результат в файле activity.log:
[2025-04-01 12:34:56] activity.INFO: Пользователь вошел в систему {"user_id":123,"ip":"192.168.1.1"}
Проблемы и решения при использовании Monolog
- Проблема: Файл лога быстро растет. Решение: Использовать ротацию с помощью RotatingFileHandler.
- Проблема: При большом количестве запросов запись в файл блокирует процесс. Решение: Настроить асинхронную обработку через очередь (см. варианты ниже).
- Ошибка: Ошибка прав доступа к файлу или директории. Решение: Проверить права на запись, создать директорию logs с правами 755.
Вариант 1: Логирование в файл с помощью встроенной функции error_log
Как записать действия пользователя в файл без дополнительных библиотек?
Использование error_log - самый простой способ. Функция может направлять сообщения в файл, указав тип 3.
<?php
function logActivity($message, $context = []) {
$logFile = __DIR__ . '/logs/user_activity.log';
$timestamp = date('Y-m-d H:i:s');
$contextStr = !empty($context) ? json_encode($context, JSON_UNESCAPED_UNICODE) : '';
$line = "[$timestamp] $message $contextStr" . PHP_EOL;
error_log($line, 3, $logFile);
}
logActivity('Пользователь зарегистрировался', ['user_id' => 55, 'email' => 'user@example.com']);
?>
Проблемы
- При параллельных запросах возможна потеря данных из-за отсутствия блокировки файла. Решение: использовать flock или перейти на Monolog.
- Нет ротации логов, файл заполняет диск. Решение: периодически архивировать через cron, либо реализовать собственную ротацию.
- Ошибка: файл не создается, если нет прав на запись. Решение: создать директорию заранее, проверить is_writable.
Цель использования: Быстрое прототипирование, маленькие проекты, где не требуется высокая производительность.
Вариант 2: Логирование в базу данных MySQL через PDO
Как хранить активность пользователей в структурированном виде для последующего анализа?
Запись в таблицу базы данных позволяет выполнять сложные запросы, фильтрацию и отчеты.
<?php
$pdo = new PDO('mysql:host=localhost;dbname=logs;charset=utf8', 'user', 'pass');
$stmt = $pdo->prepare('INSERT INTO user_activity (user_id, action, ip, user_agent, created_at) VALUES (?, ?, ?, ?, NOW())');
function logActivityDB(PDO $pdo, $userId, $action, $ip, $userAgent) {
global $stmt;
$stmt->execute([$userId, $action, $ip, $userAgent]);
}
logActivityDB($pdo, 123, 'login', '192.168.1.1', $_SERVER['HTTP_USER_AGENT'] ?? '');
?>
Проблемы
- Высокая нагрузка на БД при большом количестве логов. Решение: использовать буферизацию (накапливать пачку и вставлять одним запросом) или асинхронную очередь.
- Ошибка: дублирование записей при повторном выполнении скрипта. Решение: добавить уникальность по составному ключу или использовать транзакции.
- Проблема: блокировка таблицы при вставке. Решение: использовать InnoDB с подходящим уровнем изоляции, либо очередь.
Цель: Проекты, где важен детальный анализ логов, необходимо фильтровать по датам, пользователям, действиям.
Вариант 3: Асинхронное логирование через очередь (Redis + Resque)
Как уменьшить задержку приложения при логировании, не теряя данные?
Асинхронный подход - добавлять задачу в очередь (Redis), а фоновый воркер обрабатывает и записывает логи.
// 1. Установка php-resque: composer require chrisboulton/php-resque
// 2. Создание задания ActivityLogJob.php
<?php
class ActivityLogJob {
public function perform() {
$args = $this->args;
// Запись в файл или БД
$logFile = __DIR__ . '/logs/activity.log';
$line = date('Y-m-d H:i:s') . " {$args['message']} " . json_encode($args['context']) . "\n";
file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
}
}
// 3. В основном приложении постановка задачи
Resque::setBackend('localhost:6379');
$args = [
'message' => 'Пользователь совершил покупку',
'context' => ['order_id' => 100, 'amount' => 500]
];
Resque::enqueue('activity_log', 'ActivityLogJob', $args);
?>
Проблемы
- Необходим запущенный Redis и воркер. Решение: настроить supervisor для постоянной работы воркера.
- Сложность отладки: ошибки в воркере могут быть незаметны. Решение: добавить логирование самого воркера.
- Потеря очереди при падении Redis (без персистентности). Решение: использовать режим AOF или RDB, либо RabbitMQ с подтверждением.
Цель: Высоконагруженные проекты, где недопустимо замедление ответа клиента.
Вариант 4: Централизованный сбор логов с помощью Graylog (GELF)
Как собирать логи активности с нескольких серверов в единое хранилище?
Использование протокола GELF через Monolog позволяет отправлять логи напрямую в Graylog.
composer require graylog2/gelf-php
composer require monolog/monolog
<?php
use Monolog\Logger;
use Monolog\Handler\GelfHandler;
use Gelf\Message;
use Gelf\Transport\TcpTransport;
$transport = new TcpTransport('graylog.example.com', 12201);
$handler = new GelfHandler($transport, Logger::INFO);
$logger = new Logger('activity');
$logger->pushHandler($handler);
$logger->info('User performed action', ['user_id' => 42, 'action' => 'delete']);
?>
Проблемы
- Зависимость от внешней инфраструктуры (Graylog, Elasticsearch). Решение: развернуть локально или использовать облачный сервис.
- Перегрузка сети при большом потоке. Решение: агрегировать логи перед отправкой.
- Ошибка соединения с сервером. Решение: настроить буферизацию и повторную отправку.
Цель: Крупные распределенные системы, где требуется единая точка сбора и анализа логов.
Выбор подходящего метода зависит от масштаба проекта, требований к производительности и доступности инфраструктуры. Для небольших сайтов достаточно файла или БД, для высоконагруженных - асинхронные очереди или внешние сервисы. Важно также настроить обработку ошибок самого логирования, чтобы сбои не приводили к потере данных.
Расширенные примеры реализации логирования активности
Пример 1. Гибкий класс ActivityLogger с поддержкой нескольких обработчиков
<?php
class ActivityLogger
{
private $logger;
public function __construct(array $config)
{
$this->logger = new Logger('activity');
if (isset($config['file'])) {
$handler = new StreamHandler($config['file'], Logger::INFO);
$this->logger->pushHandler($handler);
}
if (isset($config['gelf'])) {
$transport = new TcpTransport($config['gelf']['host'], $config['gelf']['port']);
$handler = new GelfHandler($transport, Logger::INFO);
$this->logger->pushHandler($handler);
}
}
public function log($action, array $context = [])
{
$this->logger->info($action, $context);
}
}
// Использование
$config = [
'file' => __DIR__ . '/logs/activity.log',
'gelf' => ['host' => 'graylog.local', 'port' => 12201]
];
$logger = new ActivityLogger($config);
$logger->log('user_login', ['user_id' => 1, 'timestamp' => time()]);
?>
Пример 2. Пакетная запись в базу данных с использованием транзакции
<?php
$pdo = new PDO('mysql:host=localhost;dbname=logs;charset=utf8', 'user', 'pass');
$pdo->beginTransaction();
$stmt = $pdo->prepare('INSERT INTO user_activity (user_id, action, ip, created_at) VALUES (?, ?, ?, NOW())');
$batch = [
[1, 'view_page', '192.168.1.1'],
[1, 'click_link', '192.168.1.1'],
[2, 'logout', '10.0.0.1']
];
foreach ($batch as $row) {
$stmt->execute($row);
}
$pdo->commit();
?>
(в таблицу добавлены три записи, транзакция гарантирует атомарность)
Пример 3. Асинхронное логирование через RabbitMQ с подтверждением доставки
// Установка php-amqplib: composer require php-amqplib/php-amqplib
// Отправка сообщения
$connection = new AMQPStreamConnection('localhost', 5672, 'guest', 'guest');
$channel = $connection->channel();
$channel->queue_declare('activity_log', false, true, false, false);
$msgData = json_encode([
'action' => 'purchase',
'user_id' => 42,
'amount' => 1500
]);
$msg = new AMQPMessage($msgData, ['delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT]);
$channel->basic_publish($msg, '', 'activity_log');
$channel->close();
$connection->close();
// Воркер (потребление)
$callback = function ($msg) {
$data = json_decode($msg->body, true);
// запись в лог
file_put_contents('logs/activity.log', date('Y-m-d H:i:s') . " {$data['action']}: user {$data['user_id']}\n", FILE_APPEND);
$msg->delivery_info['channel']->basic_ack($msg->delivery_info['delivery_tag']);
};
$channel->basic_qos(null, 1, null);
$channel->basic_consume('activity_log', '', false, false, false, false, $callback);
while($channel->is_consuming()) {
$channel->wait();
}
?>
Результат: воркер получает сообщения из очереди и записывает их, подтверждая обработку. При сбое воркера сообщение не теряется.
Пример 4. Интеграция с обработчиками ошибок PHP для логирования исключений вместе с контекстом пользователя
<?php
set_exception_handler(function (Throwable $e) use ($logger) {
$context = [
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
'user_id' => $_SESSION['user_id'] ?? null,
'request_uri' => $_SERVER['REQUEST_URI'] ?? ''
];
$logger->error($e->getMessage(), $context);
http_response_code(500);
echo 'Произошла внутренняя ошибка';
});
set_error_handler(function ($severity, $message, $file, $line) use ($logger) {
if (error_reporting() & $severity) {
$logger->warning($message, ['file' => $file, 'line' => $line]);
}
// Не прерываем стандартный обработчик
return false;
});
?>
Теперь все ошибки и исключения будут записываться вместе с информацией о пользователе, который их спровоцировал.
Пример 5. Использование Filebeat и Elasticsearch для централизованного сбора логов
# Конфигурация Filebeat (filebeat.yml)
filebeat.inputs:
- type: log
paths:
- /var/www/html/logs/*.log
fields:
app: my_php_app
type: activity
output.elasticsearch:
hosts: ["localhost:9200"]
index: "php-activity-%{+yyyy.MM.dd}"
# Настройка logstash или прямо в Elasticsearch
На стороне PHP просто пишем в файл в формате JSON. Filebeat считывает и отправляет в Elasticsearch, где индексы можно просматривать в Kibana.