Введение в метрики для PHP разработчика

    Всем привет. Я php разработчик и в свободное время пишу телеграм ботов. Зачастую они требуют дополнительного мониторинга работоспособности, который я реализую через связку Prometheus + Grafana. Когда я решил писать статью про метрики, я сначала планировал досконально описать пошагово как настроить окружение, как разворачивать, как строить графики. Но прикинув объем материала я решил пойти по пути наименьшего сопротивления. Сделать простое приложение, засунуть его в докер, и попутно обвешать его всем необходимым. Что бы любой желающий мог самостоятельно поднять его у себя на домашней машине и посмотреть, как это работает. В итоге за вечер написал подобие магазина в виде телеграм бота. Цель статьи познакомить читателя с принципами работы с метриками, а не написать какое-то достойное приложение.

    Окружение

    Вам потребуется:

    • linux/macos

    • docker, docker-compose

    • make

    • api token для телеграм бота

    Создаем себе нового бота в телеграм, сделать это можно написав @BotFather Затем скачиваем тестовый проект

    git clone https://github.com/omentes/sample-metrics.git
    cd sample-metrics
    make up

    После того, как соберется проект, вам нужно создать файл .env и выполнить установку php пакетов

    cp .env.dist .env
    make install

    В этот момент нужно внести свой токен в .env файл, в переменную TG_API. Токен берете в телеграме, там где регистрировали нового бота.

    После этого переходим по адресу localhost:3000 и видим форму входа в Grafana. Логинимся admin/admin Там сразу должен подтянуться дашборд Metrics.

    Скорее всего сразу будут красные пометки, что источник базы данных не работает. Это связано с тем, что в запросах использовался group by, а в настройках по умолчанию включен sql mode ONLY_FULL_GROUP_BY. Его довольно просто отключить, достаточно зайти в phpMyAdmin, открыть SQL и выполнить запрос (это реально вредный совет, используем только в тестовых проектах)

    SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''));

    Если по-прежнему Grafana не ожила, значит все еще висят процессы со включенным sql mode, поэтому заходим в phpMyAdmin > Status > Processes и убиваем процессы нажатием на кнопку kill

    После этого в Grafana нажимаем на красный треугольник возле нерабочей панельки и заходим во вкладку Query, чтобы еще раз нажать Refresh. Данные должны быть доступны, и вы увидите следующее:

    Пример корректно полученных данных от сервера MySQL
    Пример корректно полученных данных от сервера MySQL

    Отлично, теперь у вас все работает. На одном из графиков уже должны быть данные от воркера, который вытягивает данные с сервера Telegram.

    Теперь можете написать своему боту в личку, начав с команд /start

    Приложение

    Ответ бота на команду /start
    Ответ бота на команду /start

    В проекте лежит дамп для базы данных, который сразу загрузил 10 товаров. 1, 2, 5 - тянется с базы данных. 3 это пагинация, 4 и 6 не требуют объяснения. Корзина тут хранится в Redis, и это можно считать оверхедом, но Redis все равно необходим для сбора метрик. Было проще всего положить в кеш. И главное быстро, я это приложение написал за вечер специально для этой статьи.

    Сценарий следующий: выбираем игрушки, и нажимаем оформить заказ. Так как цель статьи научить работать с метриками, я в этом сценарии выделил несколько метрик, которые могут быть интересны "бизнесу".

    1. Сколько у нас пользователей (всего, или за последний час/неделю)

    2. Сколько у нас реальных пользователей, которые не удалили чат с ботом

    3. Как много люди выбирают товар (ака просмотры)

    4. Какие товары заказывают чаще

    5. Сколько заказов, где есть 2, 3, 5 товаров и тд

    Помимо этого можно еще выделить метрику "работоспособности", а работает ли воркер вообще. И на эту метрику настроить оповещения, если воркер остановился и бот перестал работать.

    Метрики: источники, запросы

    Я не буду рассказывать о настройке источников, я специально в докере все прокинул, чтобы заработало из коробки, включая dashboard. В любом случае вы можете самостоятельно зайти в настройки Grafana и посмотреть, там все тривиально.

    Сколько у нас пользователей (всего, или за последний час/неделю)

    Для телеграм бота я использовал библиотеку php-telegram-bot/core, в которой уже с коробки есть интеграция с MySQL. В базу сохраняется все необходимая информация по взаимодействию с юзером. Следовательно, для построения этой метрики нам надо взять как источник MySQL и забирать данные с таблички user

    SELECT
      created_at AS "time",
      count(id) as "NEW\:"
    FROM user
    WHERE
      $__timeFilter(created_at)
    ORDER BY created_at
    Панель Users
    Панель Users

    Так выглядит запрос для получения новых юзеров за промежуток времени. Grafana требуется временной отрезок, по которому будет фильтровать данные. Даже если вам это не нужно, в любом случае одну из колонок нужно назвать time. Поэтому во второй метрике, где все юзеры за все время, все равно есть эта колонка, хоть она по факту и не используется

    SELECT
      created_at AS "time",
      count(id) AS "All Time\:"
    FROM user

    Сколько у нас реальных пользователей, которые не удалили чат с ботом

    Для того, чтобы это понять, боту нужно попробовать что-то отправить пользователю. В данном случае я сделал механизм "оповещений о новой версии".

    Панель доставленных сообщений пользователям после создания "версии"
    Панель доставленных сообщений пользователям после создания "версии"

    Если в табличке version появится новая запись с used=0, то при следующем старте воркера бот отправит всем активным чатам сообщение, и запишет информацию об этом в табличку version_notification. Таким образом можно узнать точное количество активных чатов. Запрос для таблички Delivered Version Notifications:

    SELECT
      version_notification.created_at AS "time",
      version.version as version,
      COALESCE(COUNT(version_notification.chat_id), 0) as users
    FROM version_notification
    inner join version ON version.id = version_notification.version_id
    group by version.version
    order by version.version desc

    Как много люди выбирают товар (ака просмотры)

    Этот пункт решается созданием отдельной метрики usage. Она сохраняется в момент обращения к коду пользователем.

    increase(sample_metrics_bot_usage[1m]) - берется метрика sample_metrics_bot_usage за одну минуту и считается на сколько она была увеличена.

    Использование бота видно на графике - кто-то выбирает себе слоника
    Использование бота видно на графике - кто-то выбирает себе слоника

    Какие товары заказывают чаще

    Данную метрику нужно сохранять в момент оформления заказа. Тут есть важный момент, метрика будет одна, но у нее будут параметры, в моем случае productId. Обратите внимание, что Prometheus очень сильно приболеет, если вы будете сохранять в него плохо агрегируемые данные. У меня простой пример, где может быть всего 10 вариантов. Т.е. не стоит на проде сохранять такой параметр, если у сотни тысяч товаров. Агрегируйте до категорий, либо найдите другой способ.

    Метрика по заказанным товарам
    Метрика по заказанным товарам

    Как видно на графике, я добавил 2 метрики. Первая sample_metrics_bot_item{} добавила на график все варианты с productId, а вторая sum(sample_metrics_bot_item{})это просто сумма количества всех товаров. Если навести мышкой на график, вы увидите расшифровку по количеству

    Популярность товаров в заказах
    Популярность товаров в заказах

    В данном случае самый популярный товар это productId=2, productId=4, productId=6

    Сколько заказов, где есть 2, 3, 5 товаров и тд

    Данную метрику мы тоже будем сохранять в момент создания заказа, посчитав количество товаров и отправив одну метрику с дополнительным параметром. График по метрике sample_metrics_bot_cart{}выглядит так

    График по количеству товаров в заказе
    График по количеству товаров в заказе

    На графике видно, что было три заказа. Два заказа было с двумя товарами, и один с 7ю.

    По мимо этого можно еще выделить метрику "работоспособности"

    Для метрики воркера increase(sample_metrics_bot_worker[1m]) все идентично метрики usage, с одним нюансом - надо бы добавить оповещения. Я настроил оповещения в телеграм в настройках Grafana, там все довольно просто. Сам график выглядит так:

    Настройка оповещений
    Настройка оповещений

    Выбираем раздел Alert после открытия Edit панели. Даем название, выбираем период проверки, я поставил проверить 5 секунд в течение 30 секунд. То есть по факту если пропадут данные, на 5 секунд, то в течение 30 секунд Grafana даст шанс восстановится. Затем выбирают считать сумму показателей и сравнивать сейчас с тем, что было 10 секунд назад, а потом само правило - меньше 1. Так же выбрал два состояния ниже, там есть выбор как поступать если данные не 0, а просто null, ну или таймаут.

    Метрики: пишем код

    Для сбора и публикации метрик я использовал пакет promphp/prometheus_client_php, который поддерживает php 8. Рассмотрим класс, который я написал для сохранения метрик

    <?php
    
    declare(strict_types=1);
    
    namespace SampleMetrics\Core;
    
    use SampleMetrics\Common\Config;
    use SampleMetrics\Common\Singleton;
    use Prometheus\CollectorRegistry;
    use Prometheus\Exception\MetricsRegistrationException;
    use Prometheus\Storage\Redis;
    
    class Metrics extends Singleton
    {
        private const METRIC_USAGE_PREFIX = 'usage';
    
        private const METRIC_ITEM_PREFIX = 'item';
    
        private const METRIC_CART_PREFIX = 'cart';
    
        /**
         * @var CollectorRegistry
         */
        private CollectorRegistry $registry;
    
        public function init(Config $config): self
        {
            Redis::setDefaultOptions(
                [
                    'host' => $config->getKey('redis.host'),
                    'port' => intval($config->getKey('redis.port')),
                    'database' => intval($config->getKey('redis.database')),
                    'password' => null,
                    'timeout' => 0.1, // in seconds
                    'read_timeout' => '10', // in seconds
                    'persistent_connections' => false
                ]
            );
            $this->registry = CollectorRegistry::getDefault();
    
            return $this;
        }
    
        /**
         * @return CollectorRegistry
         */
        public function getRegistry(): CollectorRegistry
        {
            return $this->registry;
        }
    
        /**
         * @param string $metricName
         * @param array  $labels
         *
         * @throws MetricsRegistrationException
         */
        public function increaseMetric(string $metricName, array $labels = []): void
        {
            $counter = $this->registry->getOrRegisterCounter('sample_metrics_bot', $metricName, 'it increases', []);
            $counter->incBy(1, $labels);
        }
    
        /**
         * @param string $metricName
         * @param array  $labels
         *
         * @throws MetricsRegistrationException
         */
        public function increaseMetricItem(string $metricName, array $labels = []): void
        {
            $counter = $this->registry->getOrRegisterCounter(
                'sample_metrics_bot',
                $metricName,
                'it increases',
                [
                    'productId'
                ]
            );
            $counter->incBy(1, $labels);
        }
    
        /**
         * @param string $metricName
         * @param array  $labels
         *
         * @throws MetricsRegistrationException
         */
        public function increaseMetricCart(string $metricName, array $labels = []): void
        {
            $counter = $this->registry->getOrRegisterCounter(
                'sample_metrics_bot',
                $metricName,
                'it increases',
                [
                    'quantity'
                ]
            );
            $counter->incBy(1, $labels);
        }
    
        /**
         * @throws MetricsRegistrationException
         */
        public function increaseUsage(): void
        {
            $this->increaseMetric(self::METRIC_USAGE_PREFIX);
        }
    
        /**
         * @param array $labels
         *
         * @throws MetricsRegistrationException
         */
        public function increaseItemMetric(array $labels = []): void
        {
            $this->increaseMetricItem(self::METRIC_ITEM_PREFIX, $labels);
        }
    
        /**
         * @param array $labels
         *
         * @throws MetricsRegistrationException
         */
        public function increaseCartMetric(array $labels = []): void
        {
            $this->increaseMetricCart(self::METRIC_CART_PREFIX, $labels);
        }

    Все метрики имеют общий префикс sample_metrics_bot, с которого начинается название каждой метрики. Обратите внимание на вызов методоа $this->registry->getOrRegisterCounter()и $counter->incBy(1, $labels)в методах increaseMetricCart()и increaseMetricItem Помните выше шла речь о дополнительных параметрах для метрик, чтобы передавать productIdи quantity Вот как раз в этом месте в вызове метода getOrRegisterCounter()объявляется, что у метрики есть один дополнительный параметр, и его значение передается в метод incBy()

    Если вы в процессе теста сохранили много ошибочных метрик, то их можно удалить с Redis при помощи консоли

    make redis
    KEY '*'
    del k

    Где k это название метрики, которые вы увидите после команды KEY

    Теперь вызовем все нужные метрики в нужных местах.

    • в двух местах (ака контролеры) вызовем $metric->increaseUsage();

    • в цикле воркера будем вызывать метрику воркера $metric->increaseMetric('worker')

    • при оформлении заказа переберем productId и тоже сохраним

      $items = $cache->getCarts($chat_id);
      foreach ($items as $item) {
          $metric->increaseItemMetric(['productId' => $item]);
      }
      $metric->increaseCartMetric(['quantity' => count($items)]);

    Все метрики попали в Redis, а теперь нужно отправить их в Prometheus. Есть два популярных способа доставки, первый это пушить в специальный сервис, а второй это публиковать в виде текста, куда будет ходить скраппер Prometheus. В моем случае настроен второй вариант. Я поднял отдельно контейнер для веб, где по урлу /metrics доступны метрики.

    Публикация метрик
    Публикация метрик

    Работает это следующим образом. В nginx я добавил конфиг

        location / {
            try_files $uri $uri/ /index.php?uri=$uri$is_args$args;
        }

    Чтобы затем в приложении получить урл странички

    <?php
    
    require __DIR__ . '/vendor/autoload.php';
    
    use Prometheus\RenderTextFormat;
    use SampleMetrics\Core\App;
    use SampleMetrics\Core\Metrics;
    
    if (isset($_REQUEST['uri']) && $_REQUEST['uri'] == '/metrics') {
        $app = App::getInstance()->init();
        $config = $app->getConfig();
        $metrics = Metrics::getInstance()->init($config);
        $renderer = new RenderTextFormat();
        $result = $renderer->render($metrics->getRegistry()->getMetricFamilySamples());
        header('Content-type: ' . RenderTextFormat::MIME_TYPE);
        echo $result;
    } else {
        echo json_encode(
            ["silence" => "gold"]
        );
    }
    
    

    Все довольно тривиально и по факту работает с документации самого пакета.

    Итоги

    Надеюсь, я смог помочь разобраться в работе с метриками, и в любом случае жду ваших комментариев. Так же заранее большое спасибо за вычитку и исправления ошибок.

    P.S. Сам телеграм бот я развернул на своем сервере, и его можно посмотреть тут

    Similar posts

    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 3

      +1

      Возможно, что это — неплохие инструкции для начинающих в этой области. Но, вот пара советов в начале статьи сразу привела меня к мысли, что тут будет много вредных советов.
      1) Положить переменные окружения в.env файл. Да, работать будет, но стоит ли так делать в продакшен окружении? Мой ответ — нет. Лучше такие вещи передавать явно.
      2) Отключение ONLY_FULL_GROUP_BY. Опять же — работать будет. MySQL с древних версий содержал в себе нарушение стандарта SQL92. И много софта было написано под это нарушение. Относительно недавно они решили больше соответстветствовать стандарту в свежих версиях. Предлагается это отключить. Я даже не знаю в чью сторону должен тут быть направлен смайлик "фейспалм"… Вроде и "ужас", но в то же время — неужели, это — лучший способ решения?

        0
        на то это и тестовый проект :) думаю стоит возле ONLY_FULL_GROUP_BY явно указать, что это вредный совет
        –2
        Кстати, ищем контрибьюторов для php-telegram-bot :)
        Если кому нибудь интересно, пишите в личку.

        Only users with full accounts can post comments. Log in, please.