Оригинал: Track Every Request: Symfony Monitoring with OpenTelemetry and Grafana

Перевод для канала Мы ж программист

Современные веб-приложения требуют прозрачности. Вам необходимо знать, что происходит внутри вашего приложения Symfony, когда пользователи взаимодействуют с ним. В этом руководстве рассказывается, как подключить OpenTelemetry, Monolog и Grafana для отслеживания поведения вашего приложения.

Что вы создадите

Вы настроите систему мониторинга, которая будет собирать три типа данных:

  • Трассировки: отслеживание запросов на протяжении всего пути по вашему приложению
  • Логи: запись событий на каждом этапе
  • Метрики: подсчет операций и измерение производительности

Все данные поступают в Grafana, где вы сможете их визуализировать и проанализировать.

Требования

Вам понадобятся:

  • Symfony 7.x
  • PHP 8.1 или выше
  • Docker и Docker Compose
  • Composer

Обзор архитектуры

Комплект работает так:

  1. Ваше приложение Symfony генерирует трассировки, журналы и метрики
  2. OpenTelemetry собирает и форматирует эти данные
  3. Коллектор OpenTelemetry принимает данные
  4. Коллектор отправляет журналы в Loki, а трассировки — в Tempo
  5. Grafana считывает данные из Loki и Tempo для отображения всей информации

Шаг 1: ставим зависимости

Начнем с добавления необходимых пакетов в ваш Symfony проект:

Bash
composer require open-telemetry/sdk
composer require open-telemetry/exporter-otlp
composer require open-telemetry/opentelemetry-auto-symfony
composer require monolog/monolog

Эти пакеты предоставят интеграцию с OpenTelemetry SDK.

Шаг 2: настраиваем сервисы Docker

Создайте файл docker-compose.yml в корне проекта:

YAML
version: '3.8'

services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.91.0
    command: ["--config=/etc/otel-collector-config.yaml"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
    ports:
      - "4317:4317"
      - "4318:4318"
      - "8888:8888"
    networks:
      - monitoring
  tempo:
    image: grafana/tempo:2.3.1
    command: ["-config.file=/etc/tempo.yaml"]
    volumes:
      - ./tempo.yaml:/etc/tempo.yaml
      - tempo-data:/tmp/tempo
    ports:
      - "3200:3200"
      - "4317"
    networks:
      - monitoring
  loki:
    image: grafana/loki:2.9.3
    ports:
      - "3100:3100"
    command: -config.file=/etc/loki/local-config.yaml
    networks:
      - monitoring
  grafana:
    image: grafana/grafana:10.2.3
    ports:
      - "3000:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
    volumes:
      - grafana-data:/var/lib/grafana
      - ./grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml
    networks:
      - monitoring
networks:
  monitoring:
    driver: bridge
volumes:
  tempo-data:
  grafana-data:

Шаг 3: настраиваем коллектор OpenTelemetry

Создайте otel-collector-config.yaml:

YAML
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 10s
    send_batch_size: 1024
exporters:
  otlp/tempo:
    endpoint: tempo:4317
    tls:
      insecure: true
  loki:
    endpoint: http://loki:3100/loki/api/v1/push
    format: json
    labels:
      attributes:
        service.name: ""
        level: ""
  logging:
    loglevel: debug
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlp/tempo, logging]
    
    logs:
      receivers: [otlp]
      processors: [batch]
      exporters: [loki, logging]

Эта конфигурация заставит коллектор получать данные через протокол OTLP, отправлять трейсы в Tempo и логировать в Loki.

Шаг 4: настраиваем Tempo

Создайте tempo.yaml:

YAML
server:
  http_listen_port: 3200

distributor:
  receivers:
    otlp:
      protocols:
        grpc:
          endpoint: 0.0.0.0:4317
storage:
  trace:
    backend: local
    local:
      path: /tmp/tempo/blocks
compactor:
  compaction:
    block_retention: 48h

Шаг 5: настраиваем источники данных Grafana

Создайте grafana-datasources.yaml:

YAML
apiVersion: 1

datasources:
  - name: Tempo
    type: tempo
    access: proxy
    url: http://tempo:3200
    uid: tempo
    jsonData:
      httpMethod: GET
      serviceMap:
        datasourceUid: tempo
  - name: Loki
    type: loki
    access: proxy
    url: http://loki:3100
    uid: loki
    jsonData:
      maxLines: 1000

Шаг 6: настраиваем OpenTelemetry в Symfony

Создайте файл конфигурации config/services.yaml или дополните существующий:

YAML
services:
    OpenTelemetry\SDK\Trace\TracerProvider:
        factory: ['App\OpenTelemetry\TracerProviderFactory', 'create']

OpenTelemetry\API\Trace\TracerInterface:
        factory: ['@OpenTelemetry\SDK\Trace\TracerProvider', 'getTracer']
        arguments:
            - 'symfony-app'
            - '1.0.0'
    App\OpenTelemetry\TracerProviderFactory: ~

Шаг 7: создаем фабрику TracerProvider

Создайте src/OpenTelemetry/TracerProviderFactory.php:

PHP
<?php

namespace App\OpenTelemetry;

use OpenTelemetry\API\Trace\TracerInterface;
use OpenTelemetry\SDK\Trace\TracerProvider;
use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor;
use OpenTelemetry\SDK\Resource\ResourceInfo;
use OpenTelemetry\SDK\Resource\ResourceInfoFactory;
use OpenTelemetry\SDK\Common\Attribute\Attributes;
use OpenTelemetry\Contrib\Otlp\SpanExporter;
use OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory;
use OpenTelemetry\SDK\Common\Export\TransportInterface;

class TracerProviderFactory
{
    public static function create(): TracerProvider
    {
        $transport = (new OtlpHttpTransportFactory())->create(
            'http://localhost:4318/v1/traces',
            'application/json'
        );
        $exporter = new SpanExporter($transport);
        $resource = ResourceInfoFactory::emptyResource()->merge(
            ResourceInfo::create(
                Attributes::create([
                    'service.name' => 'symfony-app',
                    'service.version' => '1.0.0',
                    'deployment.environment' => $_ENV['APP_ENV'] ?? 'dev',
                ])
            )
        );
        return new TracerProvider(
            new SimpleSpanProcessor($exporter),
            null,
            $resource
        );
    }
}

Эта фабрика создает трейсер, который шлет пакеты в ваш коллектор OpenTelemetry.

Шаг 8: настраиваем Monolog для OpenTelemetry

Дополним config/packages/monolog.yaml:

YAML
monolog:
    handlers:
        main:
            type: stream
            path: "%kernel.logs_dir%/%kernel.environment%.log"
            level: debug
            formatter: monolog.formatter.json

otlp:
            type: service
            id: App\Monolog\OtlpHandler
            level: info
when@prod:
    monolog:
        handlers:
            main:
                type: fingers_crossed
                action_level: error
                handler: nested
            nested:
                type: stream
                path: php://stderr
                formatter: monolog.formatter.json
            otlp:
                type: service
                id: App\Monolog\OtlpHandler
                level: warning

Шаг 9: создаем обработчик логов OTLP

Создайте src/Monolog/OtlpHandler.php:

PHP
<?php

namespace App\Monolog;

use Monolog\Handler\AbstractProcessingHandler;
use Monolog\LogRecord;
use OpenTelemetry\API\Logs\LoggerProviderInterface;
use OpenTelemetry\SDK\Logs\LoggerProvider;
use OpenTelemetry\SDK\Logs\LogRecordExporter;
use OpenTelemetry\SDK\Logs\Processor\SimpleLogRecordProcessor;
use OpenTelemetry\Contrib\Otlp\LogsExporter;
use OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory;
use OpenTelemetry\SDK\Resource\ResourceInfo;
use OpenTelemetry\SDK\Resource\ResourceInfoFactory;
use OpenTelemetry\SDK\Common\Attribute\Attributes;

class OtlpHandler extends AbstractProcessingHandler
{
    private LoggerProviderInterface $loggerProvider;
    public function __construct()
    {
        parent::__construct();
        
        $transport = (new OtlpHttpTransportFactory())->create(
            'http://localhost:4318/v1/logs',
            'application/json'
        );
        $exporter = new LogsExporter($transport);
        $resource = ResourceInfoFactory::emptyResource()->merge(
            ResourceInfo::create(
                Attributes::create([
                    'service.name' => 'symfony-app',
                ])
            )
        );
        $this->loggerProvider = new LoggerProvider(
            new SimpleLogRecordProcessor($exporter),
            $resource
        );
    }
    protected function write(LogRecord $record): void
    {
        $logger = $this->loggerProvider->getLogger('symfony-app');
        $logger->emit(
            severityText: $record->level->getName(),
            body: $record->message,
            attributes: [
                'level' => $record->level->getName(),
                'channel' => $record->channel,
                'context' => json_encode($record->context),
                'extra' => json_encode($record->extra),
            ]
        );
    }
}

Шаг 10: создаем простой контроллер

Создайте src/Controller/MonitoringDemoController.php:





PHP
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use OpenTelemetry\API\Trace\TracerInterface;
use Psr\Log\LoggerInterface;

class MonitoringDemoController extends AbstractController
{
    public function __construct(
        private TracerInterface $tracer,
        private LoggerInterface $logger
    ) {
    }
    #[Route('/api/demo', name: 'monitoring_demo', methods: ['GET'])]
    public function demo(): JsonResponse
    {
        $span = $this->tracer->spanBuilder('demo-operation')->startSpan();
        $scope = $span->activate();
        try {
            $this->logger->info('Demo endpoint called', [
                'trace_id' => $span->getContext()->getTraceId(),
                'span_id' => $span->getContext()->getSpanId(),
            ]);
            $result = $this->processData();
            $span->addEvent('Data processed successfully');
            $span->setAttribute('result.count', count($result));
            $this->logger->info('Processing completed', [
                'item_count' => count($result),
            ]);
            return new JsonResponse([
                'status' => 'success',
                'data' => $result,
                'trace_id' => $span->getContext()->getTraceId(),
            ]);
        } catch (\Exception $e) {
            $span->recordException($e);
            $span->setStatus(\OpenTelemetry\API\Trace\StatusCode::STATUS_ERROR);
            
            $this->logger->error('Processing failed', [
                'error' => $e->getMessage(),
                'trace_id' => $span->getContext()->getTraceId(),
            ]);
            throw $e;
        } finally {
            $span->end();
            $scope->detach();
        }
    }
    private function processData(): array
    {
        $span = $this->tracer->spanBuilder('process-data')->startSpan();
        $scope = $span->activate();
        try {
            $this->logger->debug('Starting data processing');
            sleep(1);
            $data = [
                'id' => 1,
                'name' => 'Sample Item',
                'timestamp' => time(),
            ];
            $this->logger->debug('Data processing complete');
            return $data;
        } finally {
            $span->end();
            $scope->detach();
        }
    }
}

Шаг 11: запускаем стек

Запустите все сервисы с помощью Docker Compose:

Bash
docker-compose up -d

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

Шаг 12: протестируем комплект

Запустите сервер разработки Symfony:

Bash
symfony server:start

Отправьте тестовый запрос:

Bash
curl http://localhost:8000/api/demo

Вы должны получить ответ в формате JSON с идентификатором трейса.

Шаг 13: смотрим данные в Grafana

Откройте Grafana на http://localhost:3000.

Просмотр трассировок

  1. Нажмите «Explore» в левом меню
  2. Выберите «Tempo» в качестве источника данных
  3. Нажмите «Search»
  4. Вы увидите список своих трассировок
  5. Нажмите на трассировку, чтобы увидеть полную информацию о диапазоне

Просмотр логов

  1. Нажмите «Explore» в левом меню
  2. Выберите «Loki» в качестве источника данных
  3. Используйте следующий запрос: {service_name=«symfony-app»}
  4. Вы увидите все журналы из вашего приложения

Сопоставление трассировок и журналов

Настоящая мощь заключается в сопоставлении трассировок и журналов. Каждая запись журнала содержит идентификатор трассировки, поэтому вы можете переходить из трассировки к соответствующим записям журнала и обратно.

Шаг 14: создаем дашборд

Создайте новый дашборд в Grafana:

  1. Нажмите «Dashboards» в левом меню
  2. Нажмите «New», затем «New Dashboard»
  3. Нажмите «Add visualization»

Добавьте панель для частоты запросов:

Plaintext
rate({service_name="symfony-app"} |= "Demo endpoint called" [5m])

Добавьте панель для уровня ошибок:

Plaintext
rate({service_name="symfony-app"} |= "error" [5m])

Дополнительно: пользовательские метрики

Вы можете добавлять пользовательские метрики для отслеживания данных, характерных для вашего бизнеса. Установите пакет метрик:

Bash
composer require open-telemetry/sdk-metrics

Создайте сервис метрик src/Metrics/MetricsService.php:

PHP
<?php

namespace App\Metrics;
use OpenTelemetry\SDK\Metrics\MeterProvider;
use OpenTelemetry\SDK\Metrics\MetricExporter\ConsoleMetricExporter;
use OpenTelemetry\SDK\Metrics\MetricReader\ExportingReader;
use OpenTelemetry\API\Metrics\MeterInterface;
class MetricsService
{
    private MeterInterface $meter;
    public function __construct()
    {
        $reader = new ExportingReader(new ConsoleMetricExporter());
        $meterProvider = MeterProvider::builder()
            ->addReader($reader)
            ->build();
        $this->meter = $meterProvider->getMeter('symfony-app');
    }
    public function recordRequestCount(string $endpoint): void
    {
        $counter = $this->meter->createCounter(
            'http.requests',
            'requests',
            'Number of HTTP requests'
        );
        $counter->add(1, ['endpoint' => $endpoint]);
    }
    public function recordProcessingTime(float $duration, string $operation): void
    {
        $histogram = $this->meter->createHistogram(
            'operation.duration',
            'ms',
            'Operation processing time'
        );
        $histogram->record($duration, ['operation' => $operation]);
    }
}

Обновите контроллер, чтобы использовать метрики:

PHP
public function demo(): JsonResponse
{
    $start = microtime(true);
    
    $span = $this->tracer->spanBuilder('demo-operation')->startSpan();
    $scope = $span->activate();

    try {
        $this->metricsService->recordRequestCount('/api/demo');
        
        $result = $this->processData();
        
        $duration = (microtime(true) - $start) * 1000;
        $this->metricsService->recordProcessingTime($duration, 'demo-operation');
        return new JsonResponse([
            'status' => 'success',
            'data' => $result,
        ]);
    } finally {
        $span->end();
        $scope->detach();
    }
}

Рекомендации для прода

При развертывании в производственной среде измените следующие настройки:

Обновите эндпоинты OpenTelemetry

Задайте переменные среды в файле .env:

Plaintext
OTEL_EXPORTER_OTLP_ENDPOINT=http://your-collector:4318
OTEL_SERVICE_NAME=symfony-app
OTEL_SERVICE_VERSION=1.0.0

Обновите фабрику, чтобы использовать эти значения:

PHP
public static function create(): TracerProvider
{
    $endpoint = $_ENV['OTEL_EXPORTER_OTLP_ENDPOINT'] ?? 'http://localhost:4318';
    
    $transport = (new OtlpHttpTransportFactory())->create(
        $endpoint . '/v1/traces',
        'application/json'
    );
    
    // Остальной код...
}

Настройка выборки

Для приложений с высокой нагрузкой используйте выборочную запись трассировок, чтобы снизить накладные расходы:

PHP
use OpenTelemetry\SDK\Trace\Sampler\ParentBased;
use OpenTelemetry\SDK\Trace\Sampler\TraceIdRatioBasedSampler;

$sampler = new ParentBased(new TraceIdRatioBasedSampler(0.1)); // Sample 10%
return TracerProvider::builder()
    ->addSpanProcessor(new SimpleSpanProcessor($exporter))
    ->setResource($resource)
    ->setSampler($sampler)
    ->build();

Защитите эндпоинты

Добавьте аутентификацию к вашему коллектору OpenTelemetry:

YAML
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
        auth:
          authenticator: basicauth

extensions:
  basicauth:
    htpasswd:
      file: /etc/otel/.htpasswd
service:
  extensions: [basicauth]
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [otlp/tempo]

Решение проблем

Трассировки не появляются в Tempo

Проверьте, что коллектор запущен:

Bash
docker-compose logs otel-collector

Проверьте, что коллектор может достучаться до Tempo:

Bash
docker-compose exec otel-collector wget -O- http://tempo:3200/ready

Логи не видно в Loki

Проверьте Loki напрямую:

Bash
curl http://localhost:3100/ready

Проверьте логи Loki:

Bash
docker-compose logs loki

Приложение не может достучаться до коллектора

Если ваше приложение Symfony работает вне Docker, ему необходимо установить соединение с Collector на вашем хосте. Измените эндпоинт:

PHP
$transport = (new OtlpHttpTransportFactory())->create(
    'http://host.docker.internal:4318/v1/traces',
    'application/json'
);

В Linux вам, возможно, придётся использовать свой реальный IP-адрес.

Что вы узнали

Теперь у вас есть полноценная система мониторинга для вашего приложения Symfony. Вы можете:

  • Отслеживать запросы в приложении с помощью распределённого трассирования
  • Собирать структурированные логи с контекстом
  • Просматривать и запрашивать как трассировки, так и логи в Grafana
  • Сопоставлять логи с трассировками с помощью идентификаторов трассировок
  • Добавлять настраиваемые метрики для бизнес-данных

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

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