Мини-гайд: как добавить логирование в Symfony через Docker + Alloy → Loki → Grafana

В этом мини-гайде мы поднимем локальный стек логирования (Alloy → Loki → Grafana) и настроим отправку JSON-логов из Symfony. Всё запускается через Docker Compose, занимает около 10 минут и полностью повторяемо.

В проекте использую свежую версию Symfony webapp:

composer create-project symfony/skeleton:"7.3.x" myapp

cd myapp

composer require webapp

php -S localhost:8080 -t public/

Эта статья — первая из цикла статей по настройке логирования и мониторинга.

1. Настройка логов Symfony (JSON и Request ID)

Для начала настроим логирование в проекте на Symfony.

Для этого используем популярную библиотеку Monolog, которая входит в состав поставки Симфони.

Во-первых, настроим, чтобы логи формировались в виде JSON, а не строкой (это вид логов по умолчанию). Для этого включим в настройках форматтер:

Файл: config/packages/monolog.yaml

monolog:
    handlers:
        main:
            type: stream
            path: "%kernel.logs_dir%/%kernel.environment%.log"
            level: debug
            channels: ["!event"]
            formatter: monolog.formatter.json    # добавляем эту строку

Теперь логи формируются в JSON.

(В примере с локальной разработкой я предполагаю, что логи сохраняются в файле. О других возможностях читай в статье, которая выйдет чуть позже))

На этом можно было бы закончить настройку, но добавим ещё один полезный элемент: request_id. Он нужен, чтобы логи одного запроса можно было отфильтровать по его ID.

Для этого создадим Middleware для запроса и кастомный обработчик Монолог:

Файл src/Middleware/RequestIdMiddleware.php

<?php

namespace App\Middleware;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Uid\Uuid;

class RequestIdMiddleware implements HttpKernelInterface
{
    public function __construct(private HttpKernelInterface $app)
    {
    }

    public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response
    {
        $requestId = $request->headers->get('X-Request-Id');

        if (!$requestId) {
            $requestId = Uuid::v7()->toRfc4122();
        }

        $request->attributes->set('request_id', $requestId);

        $response = $this->app->handle($request, $type, $catch);

        $response->headers->set('X-Request-Id', $requestId);

        return $response;
    }
}

Файл: src/Logger/RequestIdProcessor.php

<?php

namespace App\Logger;

use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
use Symfony\Component\HttpFoundation\RequestStack;

class RequestIdProcessor implements ProcessorInterface
{
    public function __construct(private RequestStack $requestStack)
    {
    }

    public function __invoke(LogRecord $record): LogRecord
    {
        $request = $this->requestStack->getCurrentRequest();
        if (!empty($request)) {
            $requestId = $request->attributes->get('request_id');
            if ($requestId) {
                $record->extra['request_id'] = $requestId;
            }
        }
        return $record;
    }
}

Для request_id можно использовать, например Uuid::v4(), я лишь упростил пример.

Далее включим эти обработчики в конфиге config/services.yaml:

services:
    App\Logger\RequestIdProcessor:
        tags:
            - { name: monolog.processor }

    App\Middleware\RequestIdMiddleware:
        decorates: http_kernel
        arguments:
            - '@App\Middleware\RequestIdMiddleware.inner'

Теперь записи логов можно будет искать по request_id.

Оф. документация: https://symfony.com/doc/current/logging/processors.html

2. Alloy (сбор логов)

Логи настроены. Теперь их нужно прочитать с диска и отправить в Loki, где они будут храниться и обрабатываться.

Для запуска Loki проще всего воспользоваться Докер образом через docker-compose.

Файл docker-compose.yml (часть, полная версия в приложении):

    alloy:
        image: grafana/alloy:v1.11.3
        container_name: alloy_cont
        restart: always
        volumes:
            - ./alloy-config.alloy:/etc/alloy/config.alloy:Z    # пробрасываем в контейнер конфиг Alloy
            - ./var/log:/logs:z    # пробрасываем в контейнер volume с логами Симфони

Файл alloy-config.alloy:

// определяем из каких файлов будем читать логи
local.file_match "symfony_logs" {
  path_targets = [
    {
      __path__ = "/logs/*.log",
      __path_exclude__ = "/logs/test.log",
      __address__ = "localhost",
      service = "myapp",    //добавляем лейблы для фильтрации логов
      environment = "local",    //добавляем лейблы для фильтрации логов
    },
  ]
}

// читаем логи и передаём на обработку
loki.source.file "symfony_logs" {
  targets    = local.file_match.symfony_logs.targets
  forward_to = [loki.process.symfony_logs.receiver]
}

// обрабатываем логи, вытаскиваем из JSON нужную информацию и добавляем её в лейблы
loki.process "symfony_logs" {
  stage.json {
    expressions = {
      level   = "level_name",
      //message = "message",
      channel = "channel",
    }
  }
  stage.labels {
    values = {
      level = "level",
      //message = "message",
      channel = "channel",
    }
  }
  forward_to = [
    loki.write.loki.receiver,
  ]
}

// отправляем обработанные логи в Loki
loki.write "loki" {
  endpoint {
    url = "http://loki_cont:3100/loki/api/v1/push"
  }
}

Готово! Простейшая конфигурация Alloy собрана.

3. Loki (хранилище)

Loki — это хранилище логов, куда потом будет обращаться Grafana.

Разворациваем так же в Докере.

Файл docker-compose.yml (часть, полная версия в приложении):

    loki:
        image: grafana/loki:3.5.8
        container_name: loki_cont
        restart: always
        command: -config.file=/etc/loki/config.yaml
        volumes:
            - loki-data:/loki    # volume для хранения данных Локи, не забудь добавить его в конце конфига (см. полный конфиг в конце статьи)
            - ./loki-config.yml:/etc/loki/config.yaml:Z    # пробрасываем в контейнер конфиг Loki
        ports:
            - "3100:3100"

Файл loki-config.yml:

auth_enabled: false

server:
    http_listen_port: 3100
    grpc_listen_port: 9095

common:
    path_prefix: /loki
    replication_factor: 1
    storage:
        filesystem:
            chunks_directory: /loki/chunks
            rules_directory: /loki/rules

schema_config:
    configs:
        - from: 2025-01-01
          store: tsdb
          object_store: filesystem
          schema: v13
          index:
              prefix: index_
              period: 24h

ingester:
    chunk_idle_period: 30m
    max_chunk_age: 1h
    lifecycler:
        address: 127.0.0.1
        ring:
            kvstore:
                store: inmemory
            replication_factor: 1
    wal:
        enabled: true
        dir: /loki/wal

compactor:
    working_directory: /loki/compactor

limits_config:
    ingestion_rate_mb: 32
    ingestion_burst_size_mb: 48
    max_cache_freshness_per_query: 10m

4. Grafana (визуализация)

Вот мы и добрались до визуализации логов.

Добавляем Графану в Докер.

Файл docker-compose.yml (часть, полная версия в приложении):

    grafana:
        image: grafana/grafana:12.4.0-19291686361
        container_name: grafana_cont
        restart: always
        volumes:
            - grafana-data:/var/lib/grafana    # volume для хранения данных Графаны, не забудь добавить его в конце конфига (см. полный конфиг в конце статьи)
        ports:
            - "3000:3000"
        environment:
            - GF_SECURITY_ADMIN=admin
            - GF_SECURITY_PASSWORD=admin
        depends_on:
            - loki

5. Запуск и настройка Grafana

После того как мы соорудили все конфиги, самое время запустить стек логирования и посмотреть воочию на логи в приятном формате.

Для этого запустим (из каталога с нашим приложением):

  • приложение Симфони (php -S localhost:8080 -t public/)
  • стек Графаны (docker-compose up -d)

Если всё выполнено правильно, то по адресу http://localhost:3000 видим вход в панель Графаны.

Image

Логинимся admin-admin (см. environment в конфиге Grafana), переходим в Connections → Data Sources и нажимаем Add Data Source.

Image

Выбираем Loki

Image

Далее заполняем только Connection URL: http://loki_cont:3100 (по имени контейнера)

Image

Далее нажимаем Save & Test и должна всплыть зелёная табличка

Image

Далее переходим в Drilldown → Logs и… вот они, заветные логи 🙂

Image

Если логов нет, возможно приложение ещё их не сгенерировало, для проверки нужно перейти на страницу приложения (http://localhost:8080), чтобы появились ошибки и сами логи.

Заключение

Как оказалось, настроить базовое окружение для логирования не так уж и сложно.

В дальнейшем цикле статей я раскрою тему настройки стека Графаны для логирования и мониторинга подробнее.

Полезные ссылки

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

https://grafana.github.io/alloy-configurator

Приложение

Полная версия docker-compose.yml

name: grafana_logger

services:
    grafana:
        image: grafana/grafana:12.4.0-19291686361
        container_name: grafana_cont
        restart: always
        volumes:
            - grafana-data:/var/lib/grafana
        ports:
            - "3000:3000"
        environment:
            - GF_SECURITY_ADMIN=admin
            - GF_SECURITY_PASSWORD=admin
        depends_on:
            - loki

    loki:
        image: grafana/loki:3.5.8
        container_name: loki_cont
        restart: always
        command: -config.file=/etc/loki/config.yaml
        volumes:
            - loki-data:/loki
            - ./loki-config.yml:/etc/loki/config.yaml:Z
        ports:
            - "3100:3100"

    alloy:
        image: grafana/alloy:v1.11.3
        container_name: alloy_cont
        restart: always
        volumes:
            - ./alloy-config.alloy:/etc/alloy/config.alloy:Z
            - ./var/log:/logs:z

volumes:
    grafana-data:
    loki-data: