Активность пользователей PHP: подходы к хранению логов и работа с ошибками

Раздел: Обработка ошибок 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.

Активность пользователей в PHP - comments

En
Activity php (php)