golang

Observability своими руками: затаскиваем Prometheus, Loki и Grafana в Go-стартап на бесплатный VPS

  • четверг, 19 февраля 2026 г. в 00:00:19
https://habr.com/ru/articles/1000778/

Я Go-разработчик из крупной Bigtech-компании и один из основателей ИИ-помощника по налаживанию отношений Ближе. По сути это телеграм-бот, который принимает вопрос от пользователя по long-polling модели, обогащает его промтом, идёт в LLM, получает ответ, отправляет обратно пользователю. Контекст диалога и пользователи хранятся в Postgres, всего один инстанс приложения на Go, также cron, который отправляет уведомления с просьбой оставить обратную связь о продукте. Docker Compose для запуска нескольких контейнеров.

Также в моей команде есть product-manager, который отвечает за развитие продукта. Ему необходимо быстро тестировать гипотезы, понимать эффективность каналов продвижения, считать вовлечённость, удержание пользователей и желательно делать это всё с минимальными тратами.

Ограничения

Для развёртывания приложения выбрал бесплатный VPS на cloud.ru, тариф Free Tier. Для проверки гипотезы 4 Gb оперативки и 30 Gb жёсткого диска хватает.

cloud.ru Free Tier: 2 vCPU (доля до 10%), 4 ГБ RAM, 30 ГБ NVMe
cloud.ru Free Tier: 2 vCPU (доля до 10%), 4 ГБ RAM, 30 ГБ NVMe

Сервис

mem_limit

mem_reservation

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
docker compose logs
логи текущего контейнера 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).

Трейсы нужны, когда запрос проходит через цепочку микросервисов. Можно увидеть, сколько запрос занимает времени в каждом сервисе, и принимать решения по оптимизации. В моём случае это один инстанс приложения и база данных. Поэтому от трейсинга я намеренно решил отказаться.

tracing dashboard example
Пример Jaeger дашборда для трейсинга

Остаются два столпа: логи и метрики.

Сравнение инструментов

Я сравнивал инструменты по популярности, лёгкости внедрения и главное по занимаемой 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 (mem_limit)

Роль

Метрики

Prometheus

512 МБ

Сбор и хранение метрик

Логи

Loki

256 МБ

Хранение логов

Сборщик логов

Promtail

64 МБ

Чтение логов из Docker и отправка в Loki (стандарт для Loki)

Визуализация

Grafana

256 МБ

Дашборды для метрик и логов

Весь мониторинг-стек — примерно 1 ГБ. Вместе с приложением (1.8 ГБ) получается 2.8 ГБ из 4 ГБ. Остаётся запас на систему и Docker.

Схема взаимодействия

observability diagram
Верхнеуровневая схема observability для моего приложения

Логи (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"}, и видим все логи приложения за выбранный период.

Grafana Explore — Loki
Логи приложения в explore

Loki работает, JSON-логи распарсены, можно фильтровать по level и, например, видеть только ошибки.

Проверка метрик через Prometheus

Переключаем datasource на Prometheus, выбираем метрику новых пользователей start_total, фильтруем по контейнеру.

Grafana Explore — Prometheus
Метрики из Prometheus

Что мне сразу не нравится. Counter в Prometheus — это монотонно растущее число. На графике мы видим лесенку: каждый /start прибавляет +1, и значение только увеличивается. Хочется видеть не нарастающий итог, а количество событий за интервал. К тому же Explore подходит для отладки, а задача изначальная была в том, чтобы отображать метрики как красивые дашборды без каких-то дополнительных запросов внутри Grafana.


Формирование дашбордов в Grafana

Bot Overview

Это то, что видит продакт, когда открывает Grafana. Три строки Stat-панелей: за 24 часа, за 7 дней, за 30 дней. Stat-панель в Grafana — это панель, которая показывает одно число крупным шрифтом. Четыре метрики в каждой строке: новые пользователи, активные пользователи, ошибки, среднее время ответа LLM.

Grafana Bot Overview
Дашборд Bot Overview

Под Stat-панелями находятся таблицы /start по источникам за 24ч, 7д, 30д. Ещё ниже находится time series команды /start по источникам с разбивкой по времени. Time Series в Grafana — это панель с графиком по времени.

Grafana time series start
Time series

Engagement & Retention (вовлечённость пользователей)

Второй дашборд нужен для более глубокого анализа. Тут retention по дням (пользователь вернулся на следующий, второй, третий день после коммуникации с ботом), воронка активности в первый день (написал одно сообщение, больше двух, больше пяти), среднее количество сообщений. Для воронки активности используется Bar Gauge — это панель с горизонтальными полосками-индикаторами. Каждая полоска показывает значение и заполняется цветом пропорционально величине (как шкала прогресса).

Grafana Engagement
Dashboard Engagement & Retention

Список дашбордов

Grafana Dashboards
Три дашборда: Bot Overview (общая картина), Engagement & Retention (вовлечённость), LLM Performance

Все дашборды хранятся как 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, вовлечённость и удержание пользователей.

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