Observability своими руками: затаскиваем Prometheus, Loki и Grafana в Go-стартап на бесплатный VPS
- четверг, 19 февраля 2026 г. в 00:00:19
Я Go-разработчик из крупной Bigtech-компании и один из основателей ИИ-помощника по налаживанию отношений Ближе. По сути это телеграм-бот, который принимает вопрос от пользователя по long-polling модели, обогащает его промтом, идёт в LLM, получает ответ, отправляет обратно пользователю. Контекст диалога и пользователи хранятся в Postgres, всего один инстанс приложения на Go, также cron, который отправляет уведомления с просьбой оставить обратную связь о продукте. Docker Compose для запуска нескольких контейнеров.
Также в моей команде есть product-manager, который отвечает за развитие продукта. Ему необходимо быстро тестировать гипотезы, понимать эффективность каналов продвижения, считать вовлечённость, удержание пользователей и желательно делать это всё с минимальными тратами.
Для развёртывания приложения выбрал бесплатный VPS на 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. И в этот остаток нужно уместить весь мониторинг.
Первый неудобный момент — при развёртывании приложения часто приходится смотреть на логи. Как это можно сделать — зайти на сервер и через команду терминала получить то, что пишет конкретный контейнер в 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.
Если вы читали «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" }
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 — самый простой тип. Монотонно растёт. Идеально для подсчёта событий.
// 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 — тот же счётчик, но с лейблами. Позволяет разбить события по категориям.
// 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 нужен для понимания распределения. Например, время ответа 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 можно будет отображать данные по перцентилям.
Теперь приложение пишет структурированные логи в stdout и отдаёт метрики на отдельном endpoint. Дальше надо поднять инфраструктуру, которая эти данные соберёт и визуализирует. Добавляем в docker-compose.yml, где уже лежат конфигурации для приложения, базы данных и крона.
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: 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: 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: 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: postgres_data: # Данные PostgreSQL loki_data: # Чанки и индексы Loki prometheus_data: # хранилище временных рядов Prometheus grafana_data: # Настройки Grafana, дашборды, плагины
Конфигурации для каждой системы должны лежать отдельно в папке config/. Docker Compose во время развёртывания монтирует эти конфиги как read-only.
# 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 скрейпит себя, чтобы понимать нагрузку на сам мониторинг, и приложение.
# 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 и несколько раз переключаешь временные рамки, кэш ускорял повторные запросы. Без него каждый запрос перечитывает чанки с диска.
# 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 умеет подхватывать источники данных и дашборды из файлов при старте контейнера. Это значит, что конфигурация версионируется в репозитории вместе с кодом без дополнительных настроек в 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. Это песочница для ручных запросов к Loki и Prometheus.
Заходим в Explore, выбираем datasource Loki, фильтруем по контейнеру {service="bot"}, и видим все логи приложения за выбранный период.

Loki работает, JSON-логи распарсены, можно фильтровать по level и, например, видеть только ошибки.
Переключаем datasource на Prometheus, выбираем метрику новых пользователей start_total, фильтруем по контейнеру.

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

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

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