Всем привет. Я 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. Сам телеграм бот я развернул на своем сервере, и его можно посмотреть тут