В этом мини-гайде мы поднимем локальный стек логирования (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 видим вход в панель Графаны.

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

Выбираем Loki

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

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

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

Если логов нет, возможно приложение ещё их не сгенерировало, для проверки нужно перейти на страницу приложения (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: