Сегодня поговорим о том, как внедрить метрики в формате OpenTelemetry в PHP монолит, построенный на фреймворке Yii2.
Спойлер: как оказалось, на этой задаче можно пару раз разочароваться в бытии разработчика сломать голову на способе сбора, отправке, промежуточных звеньях и сломанных гистограммах и счетчиках.
Предыстория: что же такое OpenTelemetry
Если верить официальному сайту, OpenTelemetry - это коллекция API, SDK и инструментов для инструментинга, генерации, сборки и экспорта телеметрии (метрик, логов и трейсов).
Фактически, это набор стандартов (спецификаций) и библиотек, реализующих этот стандарт. Многие системы мониторинга могут нативно принимать данные в формате OpenTelemetry (к примеру Victoria Metrics для метрик или Jaeger для трейсов), для остальных можно использовать OpenTelemetry Collector, позволяющий агрегировать, обогащать и экспортировать трейсы, логи и метрики в различных форматах.
Pull модель vs Push модель
До Prometheus все (или почти все) сборщики метрик работали по push модели. То есть агенты или само приложение сами отправляли данные в систему мониторинга. Например, в случае statsd приложение само по TCP или UDP протоколу подключалось к statsd и отправляло в него данные.

У данного подхода есть ряд плюсов и минусов
Плюсы:
Отлично работает для коротко живущих процессов без сохранения состояния, коими являются PHP-воркеры.
Нет необходимости настраивать политики доступа для Prometheus к экземплярам приложений.
Минусы:
В случае, если много объектов мониторинга в один момент отправят свои данные, система мониторинга может упасть.
Экспорт телеметрии может замедлять работу приложения (в случае протоколов с гарантией доставки) или терять пакеты.
Каждый экземпляр приложения должен содержать конфигурацию, куда и как нужно отправлять данные.
С приходом эры Prometheus к старому отработанному подходу добавился новый - pull подход получения данных. Он заключается в том, что Prometheus сам ходит по объектам мониторинга, опрашивает их /metrics
эндпоинт и сохраняет к себе в TSDB (Time Series Data Base).

Плюсы данного подхода:
Единое место конфигурации целей мониторинга
Система мониторинга сама опрашивает хосты, так что не будет перегружена
Отлично работает при динамическом изменении целей мониторинга
Приложению не нужно знать, куда отправлять метрики, оно их экспортирует по HTTP эндпоинту
Минусы:
Чистая pull модель не подходит для коротко живущих процессов
У каждого таргета должен быть запущен HTTP сервер с
/metrics
эндпоинтом, нужно настроить политики доступа prometheus к этому порту
Какую модель выбирать для конкретного приложения - полностью зависит от специфики приложения и окружения, в котором оно запускается.
Для PHP мой личный выбор - это OpenTelemetry, который работает по push модели, OpenTelemetry Collector для агрегации метрик и добавления лейблов, а также Victoria Metrics, собирающий метрики с коллектора по pull модели. В рамках статьи рассмотрим Prometheus вместо Victoria Metrics для упрощения локальных тестов. Для Victoria Metrics конфиг будет отличаться только тем, что метрики с OpenTelemetry Collector будет собирать VMAgent.
Подключение OpenTelemetry к PHP
Для внедрения нам нужен какой-нибудь yii проект. Я буду создавать проект из basic шаблона. В конце статьи приведу ссылку на github репозиторий для возможности запустить весь зоопарк одной командой.
Создаем проект с помощью команды из документации yii
composer create-project --prefer-dist yiisoft/yii2-app-basic basic
Сразу же заменим в docker-compose.yml
версию образа на PHP 8.2.
У меня это выглядит вот так: image: yiisoftware/yii2-php:8.2-apache
Дальше нам нужно добавить необходимые зависимости opentelemetry. Мы будем отправлять данные в open telemetry collector в формате OTLP через HTTP, для простоты рассказа не будем возиться с GRPC транспортом и расширениями.
composer require open-telemetry/sdk open-telemetry/exporter-otlp
Актуальная версия SDK на момент написания статьи - 1.2.4, exporter-otlp - 1.2.1. С ними точно будет работать :)
Для начала нужно инициализировать библиотеку OpenTelemetry, добавить в docker-compose необходимые контейнеры и рассмотреть нюансы их конфигурации.
Нам необходимо добавить контейнер opentelemetry collector для сбора и агрегации метрик, а также prometheus для хранения и просмотра.
В файл web/index.php нужно добавить код инициализации метрик, используем PsrTransportFactory
для отправки данных по протоколу HTTP. Более подробно можно ознакомиться в документации OpenTelemetry Collector. Отправка данных будет происходить в формате OTLP. Это нативный формат, полностью реализующий спецификацию.
Лучше всего использовать GRPC отправку, потому что это уменьшает объем данных, передаваемый по сети. Но для простоты рассказа и избежания сборки собственного образа с ext-grpc
будем использовать HTTP.
Итоговый файл web/index.php
<?php
use OpenTelemetry\Contrib\Otlp\MetricExporter;
use OpenTelemetry\SDK\Common\Attribute\Attributes;
use OpenTelemetry\SDK\Common\Export\Http\PsrTransportFactory;
use OpenTelemetry\SDK\Logs\NoopLoggerProvider;
use OpenTelemetry\SDK\Metrics\Data\Temporality;
use OpenTelemetry\SDK\Metrics\MeterProvider;
use OpenTelemetry\SDK\Metrics\MetricReader\ExportingReader;
use OpenTelemetry\SDK\Resource\ResourceInfo;
use OpenTelemetry\SDK\Sdk;
use OpenTelemetry\SDK\Trace\NoopTracerProvider;
use OpenTelemetry\SemConv\ResourceAttributes;
// comment out the following two lines when deployed to production
defined('YII_DEBUG') or define('YII_DEBUG', true);
defined('YII_ENV') or define('YII_ENV', 'dev');
require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../vendor/yiisoft/yii2/Yii.php';
$config = require __DIR__ . '/../config/web.php';
$resource = ResourceInfo::create(
Attributes::create([
ResourceAttributes::SERVICE_NAME => 'yii-app',
ResourceAttributes::HOST_NAME => gethostname(),
])
);
$meterProvider = MeterProvider::builder()
->setResource($resource)
->addReader(
new ExportingReader(
new MetricExporter(
PsrTransportFactory::discover()
->create(
getenv('OTEL_ENDPOINT') . '/v1/metrics',
'application/json'
),
Temporality::DELTA,
)
)
)
->build();
Sdk::builder()
->setTracerProvider(new NoopTracerProvider())
->setLoggerProvider(new NoopLoggerProvider())
->setMeterProvider($meterProvider)
->setAutoShutdown(true)
->buildAndRegisterGlobal();
(new yii\web\Application($config))->run();
Атрибут resource
– это способ передачи дополнительных данных в трейсах, логах и метриках. При использовании коллектора эти данные будут доступны в переменнойresource.attributes
для процессоров, при экспорте метрик эти атрибуты будут добавлены в качестве лейблов.
Подробно параметр Temporality::DELTA
мы рассмотрим позднее.
Теперь наше приложение при запуске будет инициализировать OpenTelemetry и пытаться отправить метрики. Пока что у него это не получится, потому что endpoint еще не существует.
Подключим opentelemetry collector и prometheus. Prometheus нужно запустить с указанием scrape target - otelcollector. Для OpenTelemetry Collector конфигурация будет несколько сложнее.
Включаем receiver в формате otlp для grpc и http на портах 4317 и 4318 соответственно
Включаем prometheus экспортер, который по адресу
http://otelcollector:8889/metrics
будет экспортировать метрики в формате prometheusДобавляем batch процессор, который группирует пакеты с данными в пачки, чтобы уменьшить количество обращений к prometheus
Добавляем delta to cumulative процессор, который преобразует метрики с temporality delta в метрики с temporality cumulative
Итоговый конфиг docker-compose.yml
services:
php:
image: yiisoftware/yii2-php:8.2-apache
volumes:
- ~/.composer-docker/cache:/root/.composer/cache:delegated
- ./:/app:delegated
ports:
- '8000:80'
environment:
OTEL_ENDPOINT: "http://otelcollector:4318"
networks:
app_net: { }
prometheus:
image: prom/prometheus
restart: unless-stopped
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
ports:
- "127.0.0.1:9090:9090"
configs:
- source: prometheus.yml
target: /etc/prometheus/prometheus.yml
networks:
app_net: { }
otelcollector:
image: otel/opentelemetry-collector-contrib:0.123.0
restart: unless-stopped
configs:
- source: open-telemetry-config.yaml
target: /etc/otelcol-contrib/config.yaml
ports:
- "127.0.0.1:8889:8889"
depends_on:
- prometheus
networks:
app_net: { }
networks:
app_net: { }
configs:
open-telemetry-config.yaml:
content: |-
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
deltatocumulative:
max_stale: 30m
exporters:
prometheus:
endpoint: 0.0.0.0:8889
resource_to_telemetry_conversion:
enabled: true
service:
pipelines:
metrics:
receivers: [otlp]
processors: [deltatocumulative, batch]
exporters: [prometheus]
prometheus.yml:
content: |-
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'otelcollector'
static_configs:
- targets: ['otelcollector:8889']
Примечание: если использовать prometheus remote write exporter, будут возникать проблемы со счетчиками в виде разрывов во времени. То есть, если значение метрики обновлялось в течение минуты, в течение этой минуты данные будут экспортироваться. Затем они перестанут, и prometheus воспримет это как сброс счетчика, но Collector запомнит значение и при обновлении передаст его без обнуления. В итоге на графике из функции rate()
получим пик, не отражающий реальной картины.
Temporality: delta vs cumulative
Темпоральность указывает на то, как необходимо воспринимать аддитивные данные. Касательно времени, это указывает, содержит ли конкретный счетчик в своем измерении предыдущие значения или только самое последнее.
Примем, что старт измерений произошел в момент времени T0 и у нас есть три измерения, которые произошли в моменты T1, T2, T3. В качестве примера рассмотрим счетчик, который каждую секунду увеличивается на 1 (Красная линия y=x
)

Temporality delta обозначает, что первое измерение захватит временной промежуток (T0;T1], второе (T1;T2], третье (T2;T3]. По графику
в момент времени T1 будет равно y(T1) = 1,
в момент времени T2 будет равно y(T2) - y(T1) = 1,
в момент времени T3 будет равно y(T3) - y(T2) = 1.
Temporality cumulative обозначает, что три измерения захватят следующие промежутки времени: (T0; T1], (T0; T2], (T0; T3]. По графику
в момент времени T1: y(T1) = 1,
в момент времени T2: y(T2) = 2,
в момент времени T3: y(T3) = 3.
Prometheus работает только с кумулятивными метриками и ожидает данные исключительно в таком варианте. В противном случае ломается работа с монотонными счетчиками.
Если есть желание и знание английского, можно обратиться к разделу Temporality в спецификации opentelemetry.
В рамках PHP приложения мы будем отправлять метрики с темпоральностью delta, opentelemetry collector будет их агрегировать и преобразовывать в cumulative с помощью deltatocumulative
процессора перед экспортом в прометеус.
Инструментинг в коде
Для инструментинга контроллеров yii создаем компонент MetricsComponent
в директории lib
. Он будет отвечать за инициализацию счетчиков и работу с ними.
Код файла lib/MetricsComponent.php
<?php
namespace app\lib;
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Metrics\HistogramInterface;
use yii\base\Component;
class MetricsComponent extends Component
{
private HistogramInterface $queryDurationSeconds;
public function init()
{
parent::init();
$meter = Globals::meterProvider()->getMeter('php-application');
$this->queryDurationSeconds = $meter->createHistogram(
name: 'query_duration_seconds',
advisory: [
'ExplicitBucketBoundaries' => [
'0.05',
'0.1',
'0.25',
'0.5',
'0.1',
'1',
'2.5',
'5',
'10',
'25',
'30',
'100',
],
]
);
}
private ?float $queryDurationSecondsStart = null;
public function startQueryDurationSecondsTiming()
{
$this->queryDurationSecondsStart = (float)microtime(true);
}
public function endQueryDurationSecondsTiming(array $attributes)
{
if (!$this->queryDurationSecondsStart) {
return;
}
$this->queryDurationSeconds->record(
microtime(true) - $this->queryDurationSecondsStart,
$attributes,
);
}
}
Давайте рассмотрим ключевые моменты. При инициализации компонента мы создаем гистограмму, отвечающую за измерение времени выполнения запроса. С помощью параметра ExplicitBucketBoundaries
мы указываем на какие группы (значения - в секундах) распределять измерения.
Функции startQueryDurationSecondsTiming
и endQueryDurationSecondsTiming
позволяют начать и завершить измерение времени продолжительности запроса. Параметр $attributes
позволяет передать набор атрибутов в формате ключ-значение для добавления их в лейблы результирующей метрики.
Теперь нужно добавить компонент в Yii приложение. Вносим соответствующие изменения в файл config/web.php
:
$config = [
...
'components' => [
...
'metrics' => ['class' => \app\lib\MetricsComponent::class],
],
...
];
Добавляем в интересующий нас контроллер начало и окончание измерения. Вносим изменения в controllers/SiteController.php
:
public function init()
{
parent::init();
$this->on(self::EVENT_BEFORE_ACTION, function () {
Yii::$app->metrics->startQueryDurationSecondsTiming();
});
$this->on(self::EVENT_AFTER_ACTION, function () {
Yii::$app->metrics->endQueryDurationSecondsTiming([
'route' => $this->route,
]);
});
}
После внесения изменений при открытии страницы http://127.0.0.1:8000 в prometheus будут отправлены метрики query_duration_seconds_bucket
, query_duration_seconds_sum
и query_duration_seconds_count
.
Для просмотра метрик можно зайти на http://127.0.0.1:9090 и отправить запрос
query_duration_seconds_count{}

и получить так желанные нами метрики :)
Далее можно посмотреть на график времени обработки запроса.
histogram_quantile(0.9, sum by (route, le) (query_duration_seconds_bucket{}))
На выходе получим примерно такого рода график

Сразу хочу вас предостеречь от попыток интерпретации графика, если запросов приходит очень мало. График будет "странный" в связи с отсутствием достаточного количества измерений в группах для построения квантилей.
Давайте попробуем сымитировать бурную деятельность нашего сервиса и добавим sleep на 10-50 мс и отправим тысячу запросов для теста работы.
Вносим изменения в метод actionIndex
SiteController
public function actionIndex()
{
usleep(rand(10_000, 50_000));
return $this->render('index');
}
И сгенерируем нагрузку в виде тысячи GET запросов с помощью команды
seq 1 1000 | xargs -I % curl -s 'http://127.0.0.1:8000' > /dev/null
И получим примерно такую гистограмму 0.9 квантили длительности запросов

При этом можно убедиться, что счетчик количества запросов работает корректно

Переход на grpc
Для продакшена лучше использовать grpc протокол для отправки данных. Для этого необходимо расширение ext-grpc
и пакет open-telemetry/transport-grpc
.
Пример кода для отправки данных по grpc
$meterProvider = MeterProvider::builder()
->setResource($resource)
->addReader(
new ExportingReader(
new MetricExporter(
(new GrpcTransportFactory())
->create(
endpoint: getenv('OTEL_ENDPOINT') . '/v1/metrics',
),
Temporality::DELTA,
)
)
)
->build();
и нужно указать в OTEL_ENDPOINT
порт 4317, а не 4318.
Для интересующихся прикладываю Git репозиторий, чтобы локально поиграться с проектом