Всем привет. Я 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. Данные должны быть доступны, и вы увидите следующее:
Отлично, теперь у вас все работает. На одном из графиков уже должны быть данные от воркера, который вытягивает данные с сервера Telegram.
Теперь можете написать своему боту в личку, начав с команд /start
Приложение
В проекте лежит дамп для базы данных, который сразу загрузил 10 товаров. 1, 2, 5 - тянется с базы данных. 3 это пагинация, 4 и 6 не требуют объяснения. Корзина тут хранится в Redis, и это можно считать оверхедом, но Redis все равно необходим для сбора метрик. Было проще всего положить в кеш. И главное быстро, я это приложение написал за вечер специально для этой статьи.
Сценарий следующий: выбираем игрушки, и нажимаем оформить заказ. Так как цель статьи научить работать с метриками, я в этом сценарии выделил несколько метрик, которые могут быть интересны "бизнесу".
Сколько у нас пользователей (всего, или за последний час/неделю)
Сколько у нас реальных пользователей, которые не удалили чат с ботом
Как много люди выбирают товар (ака просмотры)
Какие товары заказывают чаще
Сколько заказов, где есть 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
Так выглядит запрос для получения новых юзеров за промежуток времени. 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. Сам телеграм бот я развернул на своем сервере, и его можно посмотреть тут
alexxz
Возможно, что это — неплохие инструкции для начинающих в этой области. Но, вот пара советов в начале статьи сразу привела меня к мысли, что тут будет много вредных советов.
1) Положить переменные окружения в.env файл. Да, работать будет, но стоит ли так делать в продакшен окружении? Мой ответ — нет. Лучше такие вещи передавать явно.
2) Отключение ONLY_FULL_GROUP_BY. Опять же — работать будет. MySQL с древних версий содержал в себе нарушение стандарта SQL92. И много софта было написано под это нарушение. Относительно недавно они решили больше соответстветствовать стандарту в свежих версиях. Предлагается это отключить. Я даже не знаю в чью сторону должен тут быть направлен смайлик "фейспалм"… Вроде и "ужас", но в то же время — неужели, это — лучший способ решения?
setnemo Автор
на то это и тестовый проект :) думаю стоит возле ONLY_FULL_GROUP_BY явно указать, что это вредный совет