Как стать автором
Обновить

Внедряем метрики OpenTelemetry в PHP проект на Yii2

Уровень сложностиСредний
Время на прочтение10 мин
Количество просмотров959

Сегодня поговорим о том, как внедрить метрики в формате OpenTelemetry в PHP монолит, построенный на фреймворке Yii2.
Спойлер: как оказалось, на этой задаче можно пару раз разочароваться в бытии разработчика сломать голову на способе сбора, отправке, промежуточных звеньях и сломанных гистограммах и счетчиках.

Предыстория: что же такое OpenTelemetry

Если верить официальному сайту, OpenTelemetry - это коллекция API, SDK и инструментов для инструментинга, генерации, сборки и экспорта телеметрии (метрик, логов и трейсов).

Фактически, это набор стандартов (спецификаций) и библиотек, реализующих этот стандарт. Многие системы мониторинга могут нативно принимать данные в формате OpenTelemetry (к примеру Victoria Metrics для метрик или Jaeger для трейсов), для остальных можно использовать OpenTelemetry Collector, позволяющий агрегировать, обогащать и экспортировать трейсы, логи и метрики в различных форматах.

Pull модель vs Push модель

До Prometheus все (или почти все) сборщики метрик работали по push модели. То есть агенты или само приложение сами отправляли данные в систему мониторинга. Например, в случае statsd приложение само по TCP или UDP протоколу подключалось к statsd и отправляло в него данные.

Отправка метрик в statsd
Отправка метрик в statsd

У данного подхода есть ряд плюсов и минусов
Плюсы:

  1. Отлично работает для коротко живущих процессов без сохранения состояния, коими являются PHP-воркеры.

  2. Нет необходимости настраивать политики доступа для Prometheus к экземплярам приложений.

Минусы:

  1. В случае, если много объектов мониторинга в один момент отправят свои данные, система мониторинга может упасть.

  2. Экспорт телеметрии может замедлять работу приложения (в случае протоколов с гарантией доставки) или терять пакеты.

  3. Каждый экземпляр приложения должен содержать конфигурацию, куда и как нужно отправлять данные.

С приходом эры Prometheus к старому отработанному подходу добавился новый - pull подход получения данных. Он заключается в том, что Prometheus сам ходит по объектам мониторинга, опрашивает их /metrics эндпоинт и сохраняет к себе в TSDB (Time Series Data Base).

Сбор метрик с PHP приложения прометеусом
Сбор метрик с PHP приложения прометеусом

Плюсы данного подхода:

  1. Единое место конфигурации целей мониторинга

  2. Система мониторинга сама опрашивает хосты, так что не будет перегружена

  3. Отлично работает при динамическом изменении целей мониторинга

  4. Приложению не нужно знать, куда отправлять метрики, оно их экспортирует по HTTP эндпоинту

Минусы:

  1. Чистая pull модель не подходит для коротко живущих процессов

  2. У каждого таргета должен быть запущен 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)

График y=x с точками измерения счетчика
График 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{}))

На выходе получим примерно такого рода график

Гистограмма времени обработки запросов при 7 запросах
Гистограмма времени обработки запросов при 7 запросах

Сразу хочу вас предостеречь от попыток интерпретации графика, если запросов приходит очень мало. График будет "странный" в связи с отсутствием достаточного количества измерений в группах для построения квантилей.

Давайте попробуем сымитировать бурную деятельность нашего сервиса и добавим 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 квантили длительности запросов

0.9 квантиль длительности запросов при 1000 запросах
0.9 квантиль длительности запросов при 1000 запросах

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

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

Переход на 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 репозиторий, чтобы локально поиграться с проектом

Теги:
Хабы:
+2
Комментарии1

Публикации

Работа

PHP программист
79 вакансий

Ближайшие события