Я Go-разработчик из крупной Bigtech-компании и один из основателей ИИ-помощника по налаживанию отношений Ближе. По сути это телеграм-бот, который принимает вопрос от пользователя по long-polling модели, обогащает его промтом, идёт в LLM, получает ответ, отправляет обратно пользователю. Контекст диалога и пользователи хранятся в Postgres, всего один инстанс приложения на Go, также cron, который отправляет уведомления с просьбой оставить обратную связь о продукте. Docker Compose для запуска нескольких контейнеров.
Также в моей команде есть product-manager, который отвечает за развитие продукта. Ему необходимо быстро тестировать гипотезы, понимать эффективность каналов продвижения, считать вовлечённость, удержание пользователей и желательно делать это всё с минимальными тратами.
Ограничения
Для развёртывания приложения выбрал бесплатный на cloud.ru, тариф Free Tier. Для проверки гипотезы 4 Gb оперативки и 30 Gb жёсткого диска хватает.
|
Сервис |
|
|
|---|---|---|
|
PostgreSQL |
1200 МБ |
800 МБ |
|
Bot |
512 МБ |
256 МБ |
|
Cron |
128 МБ |
64 МБ |
|
Итого (приложение) |
~1.8 ГБ |
~1.1 ГБ |
Из 4 Gb уже занято почти 2 на приложение плюс система и Docker. И в этот остаток нужно уместить весь мониторинг.
Необходимость observability
Первый неудобный момент — при развёртывании приложения часто приходится смотреть на логи. Как это можно сделать — зайти на сервер и через команду терминала получить то, что пишет конкретный контейнер в stdout.
# -f — это режим следования за логами. Без него команда просто выведет текущие логи и завершится
# --tail 50 — ограничивает начальный вывод последними 50 строками
# bot — выводит логи только текущего контейнера bot
docker compose logs -f --tail 50 bot
Второй момент — продакт-менеджеру надо постоянно отправлять данные по новым пользователям, например сколько пользователей, зарегистрированных сегодня, отправило одно сообщение, от двух и от пяти. Это делается запросом в базу данных, что не всегда удобно и отнимает время.
SELECT
msg_count, -- количество сообщений
COUNT(*) AS users_count -- сколько пользователей отправило столько сообщений
FROM (
SELECT
u.id, -- ID пользователя
COUNT(m.id) AS msg_count -- считаем количество сообщений каждого пользователя
FROM users u
JOIN messages m -- соединяем с таблицей сообщений
ON m.user_id = u.id -- по ID пользователя
AND m.role = 1 -- только сообщения от пользователя (не от бота)
WHERE u.created_at >= CURRENT_DATE -- пользователь зарегистрировался сегодня
AND u.created_at < CURRENT_DATE + INTERVAL '1 day' -- до конца сегодняшнего дня
GROUP BY u.id -- группируем по пользователю
) sub -- подзапрос: каждый пользователь + его кол-во сообщений
GROUP BY msg_count -- группируем по количеству сообщений
ORDER BY msg_count; -- сортируем по возрастанию
Также появился запрос на метрики:
-
сколько пользователей нажало кнопку start (новые пользователи), распознавание динамических меток (payload после start)
-
сколько пользователей прошли онбординг и написали первое сообщение (активные пользователи)
-
пользователи, вернувшиеся на n-ый день после регистрации (retention)
Хотелось, чтобы всё это можно было наблюдать через удобный интерфейс с дашбордами и т.д. Чтобы продакт мог самостоятельно зайти и посмотреть данные, которые ему нужны. Также мне нужно было быстро посмотреть, есть ли какие-то проблемы с сервисом — количество ошибок, время ответа от LLM. Всё это необходимо было сделать в короткий срок, самостоятельно, учитывая ограничения в 2 Gb RAM.
Выбор стека
Три столпа observability
Если вы читали «Building Microservices» Сэма Ньюмана или «Microservices Patterns» Криса Ричардсона, то знаете про три столпа observability: метрики (metrics), логи (logs) и трейсы (traces).
Трейсы нужны, когда запрос проходит через цепочку микросервисов. Можно увидеть, сколько запрос занимает времени в каждом сервисе, и принимать решения по оптимизации. В моём случае это один инстанс приложения и база данных. Поэтому от трейсинга я намеренно решил отказаться.
Остаются два столпа: логи и метрики.
Сравнение инструментов
Я сравнивал инструменты по популярности, лёгкости внедрения и главное по занимаемой RAM, так как ограничение по памяти было ключевое.
Логи:
|
Инструмент |
GitHub Stars |
Мин. RAM |
Что делает |
|---|---|---|---|
|
Elasticsearch |
76k |
1-2 ГБ |
Полнотекстовый поиск, индексация всего содержимого логов |
|
Loki |
27.6k |
128-256 МБ |
Хранит логи, индексирует только лейблы (не содержимое) |
Elasticsearch — популярный инструмент для enterprise-решений, он индексирует каждое слово в каждом логе. Удобный в использовании, но по RAM не вписывается в ограничения, так как потребляет огромное количество ресурсов для процесса индексации.
Loki принадлежит компании Grafana Labs. Она была представлена в 2018 году как «Prometheus для логов». Основная идея Loki — сделать логирование максимально дешевым и простым в эксплуатации, отказавшись от полнотекстовой индексации всего содержимого строк.
Метрики:
|
Инструмент |
GitHub Stars |
Мин. RAM |
Что делает |
|---|---|---|---|
|
PostgreSQL (таблица событий) |
— |
0 МБ (уже стоит) |
Писать события в таблицу, Grafana умеет в PostgreSQL datasource |
|
ClickHouse |
45.7k |
1-2 ГБ |
Колоночная OLAP-база для аналитики |
|
Prometheus |
62.6k |
256-512 МБ |
Pull-модель, TSDB, нативная Go-библиотека |
|
VictoriaMetrics |
16.3k |
128-256 МБ |
Совместима с Prometheus, эффективнее по RAM |
ClickHouse — колоночная база, созданная для аналитики. Быстрые агрегации, отлично подходит для событий. Но минимальное потребление RAM — от 1 ГБ, в реальности ближе к 2 ГБ. Также большое время уйдёт на настройку и интеграцию. Скорее это решение для аналитиков в enterprise. Сложно и избыточно для одного сервиса.
Prometheus — есть нативная библиотека для Go. Pull-модель: сам забирает метрики у приложений (не нужно настраивать отправку в коде). Просто настраивается и интегрируется. Много туториалов на просторах интернета. Огромная экосистема: тысячи готовых дашбордов Grafana и экспортёров под любую БД или сервис. Из минусов — плохо масштабируется, нет долгосрочного хранения. Сейчас метрики нужны в динамике (за последние 30 дней), а пользователей не так много, чтобы думать о масштабировании.
VictoriaMetrics — отличная альтернатива, совместимая с Prometheus, и даже более экономная по памяти. Но Prometheus уже де-факто стандарт, документация и примеры для Go заточены под него, а разница в 100-200 МБ не критична.
Также можно сохранять метрики в PostgreSQL, для этого создать таблицу эвентов, а Grafana умеет из коробки забирать данные. Для начала можно было бы так сделать, но создавать отдельную таблицу и миграции по времени более накладно, плюс Postgres лучше работает, когда 70–80% происходит чтение из базы и остальное запись, здесь же происходит обратная ситуация.
Визуализация:
Grafana умеет работать со всеми вышеперечисленными системами. Один UI для всего. Плюс дашборды могут храниться в репозитории вместе с кодом. Потребляет 128–256 МБ RAM.
Итоговый стек
|
Компонент |
Инструмент |
RAM ( |
Роль |
|---|---|---|---|
|
Метрики |
Prometheus |
512 МБ |
Сбор и хранение метрик |
|
Логи |
Loki |
256 МБ |
Хранение логов |
|
Сборщик логов |
Promtail |
64 МБ |
Чтение логов из Docker и отправка в Loki (стандарт для Loki) |
|
Визуализация |
Grafana |
256 МБ |
Дашборды для метрик и логов |
Весь мониторинг-стек — примерно 1 ГБ. Вместе с приложением (1.8 ГБ) получается 2.8 ГБ из 4 ГБ. Остаётся запас на систему и Docker.
Схема взаимодействия
Логи (push-модель). Приложение пишет логи в stdout. Promtail через Docker socket читает stdout/stderr всех контейнеров проекта и пушит их в Loki. Loki сохраняет логи на диск.
Метрики (pull-модель). В приложении добавляем handler, который слушает endpoint /metrics. Prometheus раз в 10 секунд приходит и забирает текущие значения метрик. Преимущество pull-модели в том, что приложение не зависит от Prometheus — если мониторинг упал, приложение продолжает работать.
Визуализация. Grafana подключена к Prometheus и Loki, один UI — два источника данных.
Что добавляем в код
Что нужно изменить в самом сервисе, чтобы он начал отдавать логи и метрики.
Структурированные логи
Первое, что нужно Loki — это структурированные логи в формате JSON. Формат JSON превращает лог в запись, которую легко фильтровать, индексировать и анализировать автоматически.
В Go начиная с версии 1.21 есть стандартный пакет log/slog, и он поддерживает структурированные логи. Как плюс — не нужно добавлять внешние зависимости.
Инициализация логгера выглядит так:
// cmd/service/main.go
logHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
logger := slog.New(logHandler).With("service", "bot")
Level: slog.LevelInfo задаёт минимальный уровень логирования. Это означает, что в stdout будут попадать только логи с уровнем Info и выше. Каждая запись автоматически будет содержать "service": "bot" для фильтрации логов от инстанса приложения, например cron будет иметь другой тип "service": "cron".
Пример вызова логгера и того, что будет записываться в stdout.
logger.Info("bot started successfully")
{
"time":"2026-02-03T14:29:10.080Z",
"level":"INFO",
"msg":"bot started successfully",
"service":"bot"
}
HTTP endpoint для Prometheus
Prometheus работает по pull-модели — он сам приходит на endpoint и забирает метрики. Нужно поднять HTTP-сервер, который будет отдавать метрики по запросу /metrics.
// cmd/service/main.go
import "github.com/prometheus/client_golang/prometheus/promhttp"
metricsServer := &http.Server{Addr: ":9091", Handler: promhttp.Handler()}
go func() {
logger.Info("starting metrics server", "addr", ":9091")
if err := metricsServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("metrics server error", "error", err)
}
}()
Сервер слушает порт 9091 в отдельной горутине, а значит, не блокирует основной поток приложения. promhttp.Handler() — стандартный хендлер, который отдаёт все зарегистрированные метрики в формате Prometheus.
Также добавим graceful shutdown для metrics server. Если при остановке контейнера Prometheus пытается получить данные, а сервер уже упал, то в логах будут ошибки.
// cmd/service/main.go — в блоке graceful shutdown
<-ctx.Done()
logger.Info("shutdown signal received, starting graceful shutdown")
// Останавливаем бота
logger.Info("stopping telegram bot")
b.Stop()
// Останавливаем metrics server
logger.Info("stopping metrics server")
metricsCtx, metricsCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer metricsCancel()
if err := metricsServer.Shutdown(metricsCtx); err != nil {
logger.Error("failed to shutdown metrics server", "err", err)
}
// Закрываем пул соединений с БД
logger.Info("closing database connection")
pgConn.Close()
Бот выключаем первым, чтобы не принимать новые сообщения. Metrics server последним из сервисов, чтобы Prometheus успел забрать финальные значения.
Добавление метрик
Для отправки событий воспользуемся promauto. Это обёртка от Prometheus, которая автоматически регистрирует метрики при создании. Ниже детальнее, какие метрики мы будем использовать.
Counter (событие произошло)
Counter — самый простой тип. Монотонно растёт. Идеально для подсчёта событий.
// internal/pkg/metrics/metrics.go
// Новые пользователи — простой счётчик
NewUsersTotal = promauto.NewCounter(
prometheus.CounterOpts{
Name: "new_users_total",
Help: "Total number of newly registered users",
},
)
// Успешно отправленные review-уведомления
ReviewSentTotal = promauto.NewCounter(
prometheus.CounterOpts{
Name: "review_sent_total",
Help: "Total number of successfully sent review notifications",
},
)
При регистрации нового пользователя:
// internal/app/usecase/start_chat.go
user = &models.User{
ID: request.UserID,
CreatedAt: time.Now(),
}
metrics.NewUsersTotal.Inc() // +1 к счётчику
В Prometheus можно будет отфильтровать по событию new_users_total.
CounterVec (событие + категория)
CounterVec — тот же счётчик, но с лейблами. Позволяет разбить события по категориям.
// internal/pkg/metrics/metrics.go
// Команды /start по источнику — откуда пришёл пользователь
StartTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "start_total",
Help: "Total number of /start commands by source",
},
[]string{"source"}, // лейбл
)
// Ошибки по типам — какие ошибки и сколько
MessagesErrorsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "errors_total",
Help: "Total number of message processing errors by type",
},
[]string{"type"}, // лейбл
)
При нажатии /start фиксируем источник из UTM-метки (payload ссылки):
// internal/app/delivery/bot/handle_start.go
source := "direct"
if payload := c.Message().Payload; payload != "" {
source = payload
}
metrics.StartTotal.WithLabelValues(source).Inc()
Теперь в Prometheus появляются отдельные ряды: start_total{source="direct"}, start_total{source="my_ad"} и т.д.
Histogram (распределение значений)
Histogram нужен для понимания распределения. Например, время ответа LLM. Бакеты — это границы интервалов в секундах. LLM в reasoning mode думает долго, поэтому бакеты до 180 секунд. Так мы будем понимать, сколько запросов попало в определённый bucket.
// internal/pkg/metrics/metrics.go
LLMRequestDuration = promauto.NewHistogram(
prometheus.HistogramOpts{
Name: "llm_request_duration_seconds",
Help: "Duration of LLM requests in seconds",
Buckets: []float64{1, 2, 5, 10, 20, 30, 60, 120, 180},
},
)
Далее замеряем время вызова LLM:
// internal/app/usecase/handle_message.go
llmStart := time.Now()
response, err := s.llmClient.Send(ctx, llmMessages, models.ThinkingLLMMode)
metrics.LLMRequestDuration.Observe(time.Since(llmStart).Seconds())
Теперь в Grafana можно будет отображать данные по перцентилям.
Конфигурация в docker compose
Теперь приложение пишет структурированные логи в stdout и отдаёт метрики на отдельном endpoint. Дальше надо поднять инфраструктуру, которая эти данные соберёт и визуализирует. Добавляем в docker-compose.yml, где уже лежат конфигурации для приложения, базы данных и крона.
Loki
loki:
image: grafana/loki:3.0.0 # Стабильная версия Loki 3.x
container_name: sex_doctor_loki
restart: unless-stopped # Перезапуск при падении, но не при ручной остановке
mem_limit: 256m # ограничение RAM сверху
mem_reservation: 128m # Гарантированный минимум RAM
volumes:
- ./config/loki/loki-config.yaml:/etc/loki/local-config.yaml:ro # Конфиг read-only
- loki_data:/loki # Персистентный volume для чанков и индексов
command: -config.file=/etc/loki/local-config.yaml
networks:
app_network:
ipv4_address: 172.25.0.10 # Фиксированный IP внутри Docker-сети
healthcheck: # Grafana стартует только когда Loki готов
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1"]
interval: 30s
timeout: 10s
retries: 5
Без ограничений mem_limit Loki с дефолтными настройками может раздуться и заполнить оставшуюся память. Volume loki_data — чтобы логи переживали перезапуск контейнера. Healthcheck нужен для того, чтобы Grafana не стартовала раньше Loki.
Promtail
promtail:
image: grafana/promtail:3.0.0
container_name: sex_doctor_promtail
restart: unless-stopped
mem_limit: 64m
mem_reservation: 32m
volumes:
- ./config/promtail/promtail-config.yaml:/etc/promtail/config.yaml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
command: -config.file=/etc/promtail/config.yaml
depends_on:
loki:
condition: service_healthy # Ждём пока Loki будет ready
networks:
app_network:
ipv4_address: 172.25.0.11
Обратите внимание на /var/run/docker.sock:/var/run/docker.sock:ro. Promtail получает доступ к Docker socket, чтобы автоматически находить контейнеры и читать их stdout/stderr. Флаг :ro (read-only) важен для безопасности: Promtail может только читать информацию о контейнерах, но не управлять ими.
Prometheus
prometheus:
image: prom/prometheus:v2.51.0
container_name: sex_doctor_prometheus
restart: unless-stopped
mem_limit: 512m
mem_reservation: 256m
volumes:
- ./config/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus # Данные метрик переживают перезапуск
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=30d' # Храним метрики 30 дней
- '--web.enable-lifecycle' # Можно перезагрузить конфиг без рестарта
expose:
- "9090" # Порт только внутри Docker-сети
# порты закрыты снаружи, доступ к Prometheus только через Grafana
networks:
app_network:
ipv4_address: 172.25.0.12
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:9090/-/healthy || exit 1"]
interval: 30s
timeout: 10s
retries: 5
expose открывает порт только внутри Docker-сети, а ports пробрасывает наружу. Prometheus хранит все метрики — ему не нужно быть доступным из интернета. Доступ к данным только через Grafana.
Grafana
grafana:
image: grafana/grafana:10.4.0
container_name: sex_doctor_grafana
restart: unless-stopped
mem_limit: 256m
mem_reservation: 128m
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN} # Логин из .env
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD} # Пароль из .env
GF_USERS_ALLOW_SIGN_UP: "false" # Запрет регистрации
# Защита от перебора пароля
GF_SECURITY_DISABLE_BRUTE_FORCE_LOGIN_PROTECTION: "false"
GF_AUTH_LOGIN_MAXIMUM_INACTIVE_LIFETIME_DURATION: "7d"
GF_AUTH_LOGIN_MAXIMUM_LIFETIME_DURATION: "30d"
GF_AUTH_BASIC_ENABLED: "true"
volumes:
- grafana_data:/var/lib/grafana
- ./config/grafana/provisioning:/etc/grafana/provisioning:ro # Datasources и дашборды
- ./config/grafana/dashboards:/var/lib/grafana/dashboards:ro # JSON-файлы дашбордов
ports:
- "3000:3000" # Единственный порт, открытый наружу
depends_on:
prometheus:
condition: service_healthy
loki:
condition: service_healthy
networks:
app_network:
ipv4_address: 172.25.0.13
Grafana — единственный сервис мониторинга с открытым портом наружу. Здесь нужен доступ извне через веб-браузер. Для безопасности логин и пароль берутся только из .env-файла, добавлен запрет регистрации новых пользователей, также защита от брутфорса, ограничение времени жизни сессии.
Volumes и сеть
volumes:
postgres_data: # Данные PostgreSQL
loki_data: # Чанки и индексы Loki
prometheus_data: # хранилище временных рядов Prometheus
grafana_data: # Настройки Grafana, дашборды, плагины
Конфигурация систем мониторинга
Конфигурации для каждой системы должны лежать отдельно в папке config/. Docker Compose во время развёртывания монтирует эти конфиги как read-only.
Prometheus
# config/prometheus/prometheus.yml
global:
scrape_interval: 15s # По умолчанию опрашивать каждые 15 секунд
evaluation_interval: 15s # как часто пересчитывать правила метрик
scrape_configs:
# Prometheus мониторит сам себя — базовые метрики runtime
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
# Наш бот — основной target
- job_name: 'bot'
static_configs:
- targets: ['bot:9091'] # Docker DNS: имя сервиса + порт
scrape_interval: 10s # Приложение скрейпим чаще — 10 секунд вместо 15
Prometheus скрейпит себя, чтобы понимать нагрузку на сам мониторинг, и приложение.
Loki
# config/loki/loki-config.yaml
auth_enabled: false # аутентификация не нужна
server:
http_listen_port: 3100
grpc_listen_port: 9096
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem: # Храним на диске
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1 # Один инстанс — репликация не нужна
ring:
kvstore:
store: inmemory # Координация в памяти — один инстанс
query_range:
results_cache:
cache:
embedded_cache:
enabled: true
max_size_mb: 100 # Кэш запросов — ускоряет повторные запросы в Grafana
schema_config:
configs:
- from: 2020-10-24
store: tsdb # TSDB-хранилище для индексов
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h # Новый индекс каждые 24 часа
limits_config:
retention_period: 168h # 7 дней — компромисс между историей и диском
compactor:
working_directory: /loki/compactor
compaction_interval: 10m # Сжимаем данные каждые 10 минут
retention_enabled: true # Включаем автоудаление старых логов
retention_delete_delay: 2h # Удаляем не сразу. Страховка от случайного удаления
retention_delete_worker_count: 150
delete_request_store: filesystem
Compaction в Loki — это не тяжёлая операция, как в классических базах данных. Она объединяет мелкие индексные файлы в более крупные, чтобы при поиске не открывать сотни маленьких файлов, также применяет retention — удаляет данные старше заданного срока. Метрики в Prometheus храним 30 дней, а вот логи дороже по диску, поэтому 7 дней будет достаточно. embedded_cache: max_size_mb нужен для того, чтобы когда в Grafana открываешь Explore и несколько раз переключаешь временные рамки, кэш ускорял повторные запросы. Без него каждый запрос перечитывает чанки с диска.
Promtail
# config/promtail/promtail-config.yaml
server:
http_listen_port: 9080
positions:
filename: /tmp/positions.yaml # Запоминает, до какого момента прочитал, чтобы не дублировать после рестарта
clients:
- url: http://loki:3100/loki/api/v1/push # Куда пушить логи
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock # Читаем список контейнеров через Docker API
refresh_interval: 5s # Обновляем каждые 5 секунд
relabel_configs:
# Имя контейнера как лейбл: /sex_doctor_bot → container="sex_doctor_bot"
- source_labels: ['__meta_docker_container_name']
regex: '/(.*)'
target_label: 'container'
# Извлекаем имя сервиса: /sex_doctor_bot → service="bot"
- source_labels: ['__meta_docker_container_name']
regex: '/sex_doctor_(.*)'
target_label: 'service'
# Фильтр: берём логи ТОЛЬКО от контейнеров проекта
- source_labels: ['__meta_docker_container_name']
regex: '/sex_doctor_.*'
action: keep # Игнорируем все что не подходит
pipeline_stages:
# Парсим JSON-логи от бота
- match:
selector: '{service="bot"}' # Только для логов бота
stages:
- json:
expressions:
level: level # Извлекаем поле "level" из JSON
msg: msg
time: time
- labels:
level: # Превращаем level в лейбл Loki
В docker_sd_configs описываем, как Promtail подключается к Docker socket и автоматически обнаруживает все контейнеры, которые нам нужны. pipeline_stages нужны для того, чтобы Promtail парсил JSON-логи и превращал поле level в лейбл. Благодаря этому в Grafana можно будет фильтровать логи по level.
Grafana
Grafana умеет подхватывать источники данных и дашборды из файлов при старте контейнера. Это значит, что конфигурация версионируется в репозитории вместе с кодом без дополнительных настроек в UI.
# config/grafana/provisioning/datasources/datasources.yaml
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
uid: prometheus
access: proxy # Grafana проксирует запросы Prometheus
url: http://prometheus:9090
isDefault: true # Prometheus как datasource по умолчанию
editable: true
- name: Loki
type: loki
access: proxy
url: http://loki:3100
editable: true
# config/grafana/provisioning/dashboards/dashboards.yaml
apiVersion: 1
providers:
- name: 'default'
orgId: 1
folder: ''
type: file
disableDeletion: false
updateIntervalSeconds: 10 # Проверяет обновления каждые 10 секунд
options:
path: /var/lib/grafana/dashboards # Путь к JSON-файлам дашбордов в контейнере
Дашборды лежат в config/grafana/dashboards/ как JSON-файлы и монтируются в контейнер:
config/grafana/
├── dashboards/
│ ├── bot-overview.json # Общий обзор: новые, активные пользователи, ошибки
│ ├── engagement.json # Вовлечённость пользователей и retention
│ └── llm-performance.json # Скорость ответа LLM
└── provisioning/
├── datasources/
│ └── datasources.yaml # Prometheus + Loki
└── dashboards/
└── dashboards.yaml # Путь к JSON-файлам
При развёртывании инстанса Grafana читает provisioning-файлы, подключает Prometheus и Loki как источники данных, загружает три дашборда. Как конфигурировать UI через JSON-файлы, опишу чуть позже.
Логи и метрики через Explore
Чтобы убедиться, что всё, что мы сделали выше, работает, можно воспользоваться разделом Explore. Это песочница для ручных запросов к Loki и Prometheus.
Проверка логов
Заходим в Explore, выбираем datasource Loki, фильтруем по контейнеру {service="bot"}, и видим все логи приложения за выбранный период.
Loki работает, JSON-логи распарсены, можно фильтровать по level и, например, видеть только ошибки.
Проверка метрик через Prometheus
Переключаем datasource на Prometheus, выбираем метрику новых пользователей start_total, фильтруем по контейнеру.
Что мне сразу не нравится. Counter в Prometheus — это монотонно растущее число. На графике мы видим лесенку: каждый /start прибавляет +1, и значение только увеличивается. Хочется видеть не нарастающий итог, а количество событий за интервал. К тому же Explore подходит для отладки, а задача изначальная была в том, чтобы отображать метрики как красивые дашборды без каких-то дополнительных запросов внутри Grafana.
Формирование дашбордов в Grafana
Bot Overview
Это то, что видит продакт, когда открывает Grafana. Три строки Stat-панелей: за 24 часа, за 7 дней, за 30 дней. Stat-панель в Grafana — это панель, которая показывает одно число крупным шрифтом. Четыре метрики в каждой строке: новые пользователи, активные пользователи, ошибки, среднее время ответа LLM.
Под Stat-панелями находятся таблицы /start по источникам за 24ч, 7д, 30д. Ещё ниже находится time series команды /start по источникам с разбивкой по времени. Time Series в Grafana — это панель с графиком по времени.
Engagement & Retention (вовлечённость пользователей)
Второй дашборд нужен для более глубокого анализа. Тут retention по дням (пользователь вернулся на следующий, второй, третий день после коммуникации с ботом), воронка активности в первый день (написал одно сообщение, больше двух, больше пяти), среднее количество сообщений. Для воронки активности используется Bar Gauge — это панель с горизонтальными полосками-индикаторами. Каждая полоска показывает значение и заполняется цветом пропорционально величине (как шкала прогресса).
Список дашбордов
Все дашборды хранятся как JSON-файлы в config/grafana/dashboards/ и подгружаются автоматически при деплое инстанса Grafana.
Как устроен дашборд внутри
Дашборд в Grafana конфигурируется через JSON-файл. Можно собрать в UI и экспортировать, а можно написать руками. Разберём структуру на примере Bot Overview.
Верхний уровень — метаданные дашборда:
{
"uid": "bot-overview",
"title": "Bot Overview",
"tags": ["bot"],
"refresh": "30s",
"time": { "from": "now-24h", "to": "now" },
"panels": [ ... ]
}
refresh: "30s" — дашборд автообновляется каждые 30 секунд. time — временной диапазон по умолчанию. Конфигурацию панелей описываем в массиве panels.
Дашборд — это сетка шириной 24 колонки. Каждая панель занимает прямоугольник, заданный через gridPos:
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }
Где w — ширина (из 24 колонок), h — высота (в условных единицах), x — отступ слева, y — позиция по вертикали.
Вот как устроена одна Stat-панель целиком:
{
"title": "Новые пользователи (24ч)",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{
"expr": "round(increase(new_users_total[24h]))",
"legendFormat": "Новые"
}
],
"fieldConfig": {
"defaults": {
"thresholds": {
"steps": [
{ "color": "blue", "value": null },
{ "color": "green", "value": 10 }
]
},
"decimals": 0
}
},
"options": {
"colorMode": "background",
"graphMode": "area"
}
}
type: "stat" — большая цифра. targets.expr — PromQL-запрос. increase(...) считает прирост счётчика за [24h] период (сколько новых пользователей за 24 часа). round(...) округляет результат до целого числа. thresholds — пороги цветов: меньше десяти — синий, от десяти — зелёный. colorMode: "background" заливает весь фон панели, а не только цифру. graphMode: "area" добавляет маленький график-заливку (спарклайн) на фоне числа. Показывает динамику значения за выбранный период.
Проблема с отображением новых меток и её решение
После деплоя нового функционала я заметил, что при первом попадании метрики она показывается как 0 вместо единицы.
Особенно это заметно на панели по времени time series.
{
"expr": "round(increase(start_total[$__interval]))",
"legendFormat": "{{source}}",
"interval": "1m"
}
Почему так происходит
Всё дело в том, что для расчёта increase() нужны минимум две точки в окне, чтобы вычислить разницу. А когда метка в time series появляется впервые, у неё ещё нет точек. Считать разницу не из чего, поэтому increase() возвращает 0.
Решение
Если при старте приложения вызвать, например, .WithLabelValues(...) (без .Inc()), Prometheus начнёт скрейпить эту time series сразу со значением 0. Добавляем функцию и инициализируем её в main:
// metrics.go
func Init() {
// /start напрямую
StartTotal.WithLabelValues("direct")
//остальные метрики
...
}
// main.go
metrics.Init()
Что данным способом не решается
start_total{source} — метрика, где source приходит из payload команды /start. Преинициализировать динамическую метку нельзя, а значит, для каждой новой метки первый increase() покажет 0 вместо единицы. Можно было хранить известные значения source и преинициализировать их в main. Мы с продукт-менеджером решили, что такая погрешность в одного пользователя на новый источник приемлема для нас.
Итог
Была поставлена задача создать удобный observability для разработчика и продакт-менеджера в короткие сроки и с ограничением по памяти. Что в итоге было сделано:
-
Выбран стек технологий
Prometheus + Loki + Promtail + Grafana, всё это уместилось в 1 Gb оперативной памяти, Out-Of-Memory killer ни разу не сработал. -
Добавлены структурированные логи в код приложения, метрики для подсчёта событий с категориями, гистограммы с распределением по бакетам, отдельный HTTP-сервер, который отдаёт метрики по pull-модели.
-
Добавлена конфигурация для docker compose для деплоя, Prometheus и Loki закрыты от внешнего доступа, дашборды Grafana хранятся в репозитории, в Grafana включена защита от брутфорса и отключена регистрация.
-
Были добавлены три дашборда, где показываются количество ошибок за определённое время, время ответа LLM, вовлечённость и удержание пользователей.
-
Выявлены проблемы отображения меток на дашборде, найдено решение для статических меток и договорённость с продакт-менеджером по поводу динамических.
Автор: krus210
