Observability
Система наблюдаемости (observability) для мониторинга, логирования и трейсинга.
Overview
Модуль backend/src/common/observability/ предоставляет три столпа observability:
| Компонент | Инструмент | Назначение |
|---|---|---|
| Logs | Pino → Loki | Структурированные логи с контекстом запроса |
| Metrics | prom-client → Prometheus | Числовые метрики (latency, counters) |
| Traces | OpenTelemetry → Tempo | Распределённый трейсинг запросов |
Структура модуля
backend/src/common/observability/
├── index.ts # Экспорты модуля
├── request-context.ts # AsyncLocalStorage для контекста запроса
├── logger.ts # Unified logger с auto-inject контекста
├── request-logger.hook.ts # Fastify hook для HTTP логирования
├── business-events.ts # Типизированные бизнес-события
├── metrics.ts # Prometheus метрики
├── metrics.routes.ts # /metrics эндпоинт
├── tracing.ts # OpenTelemetry SDK initialization
└── http-client.ts # HTTP client с трейсингом
1. Request Context
Файл: request-context.ts
Использует AsyncLocalStorage для хранения контекста запроса, который автоматически пробрасывается через async chain.
Interface
interface RequestContext {
requestId: string; // UUID v4
traceId?: string; // OpenTelemetry trace ID
userId?: string; // После auth middleware
method: string; // HTTP method
path: string; // Request path
startTime: number; // Timestamp начала
}
API
| Функция | Описание |
|---|---|
getRequestContext() | Получить текущий контекст |
getRequestId() | Shorthand для requestId |
getUserId() | Shorthand для userId |
runWithContext(ctx, callback) | Выполнить callback с контекстом |
updateContext(updates) | Обновить контекст (например, добавить userId) |
Использование
import { getRequestId, getUserId } from '@common/observability';
// В любом сервисе — контекст доступен автоматически
const requestId = getRequestId(); // 'abc-123-...'
const userId = getUserId(); // 'user-xyz' (если авторизован)
2. Logger
Файл: logger.ts
Unified logger на базе Pino с автоматическим добавлением контекста запроса.
Features
- Автоматически добавляет
requestId,userId,traceIdиз контекста - Поддержка обоих стилей:
logger.info('msg', { data })иlogger.info({ data }, 'msg') - JSON формат для Loki
- pino-pretty в development (если
LOG_PRETTY=true)
API
import { logger } from '@common/observability';
logger.info('User logged in', { action: 'login' });
logger.error('Failed to process', { error: err.message });
logger.warn('Rate limit approaching', { count: 95, limit: 100 });
logger.debug('Cache hit', { key: 'user:123' });
// Child logger для домена
const domainLogger = logger.child({ domain: 'referrals' });
domainLogger.info('Passive income claimed', { amount: 50 });
Output Format
{
"level": 30,
"time": "2026-01-22T10:30:00.000Z",
"service": "goloot-backend",
"requestId": "abc-123-def",
"userId": "user-xyz",
"msg": "User logged in",
"action": "login"
}
Log Levels
Pino использует числовые уровни логирования:
| Level | Number | Описание |
|---|---|---|
trace | 10 | Очень детальная отладка |
debug | 20 | Debug информация |
info | 30 | Нормальные операции |
warn | 40 | Предупреждения |
error | 50 | Ошибки |
fatal | 60 | Критические ошибки |
Конфигурация:
| ENV Variable | Описание | Default |
|---|---|---|
LOG_LEVEL | Минимальный уровень логов | info |
LOG_PRETTY | Человекочитаемый формат (dev) | false |
LOG_LEVEL=info → приложение выводит только info, warn, error, fatal.
LOG_LEVEL=debug → добавляются debug логи.
Это влияет на то, что попадает в stdout и далее в Loki/Grafana. Подробнее: Log Architecture
3. HTTP Logging
Файл: request-logger.hook.ts
Fastify hook для автоматического логирования HTTP запросов.
Что логируется
На входе:
{
"msg": "[HTTP] Incoming request",
"method": "POST",
"path": "/api/cases/open",
"requestId": "abc-123"
}
На выходе:
{
"msg": "[HTTP] Response",
"method": "POST",
"path": "/api/cases/open",
"statusCode": 200,
"duration": 45,
"requestId": "abc-123",
"userId": "user-xyz"
}
Интеграция
// backend/src/server.ts
import { registerRequestLogging } from '@common/observability';
const server = fastify({ ... });
registerRequestLogging(server);
4. Business Events
Файл: business-events.ts
Типизированное логирование бизнес-событий для аналитики.
Event Types
enum BusinessEventType {
// Economy
SCRAP_EARNED = 'economy.scrap_earned',
SCRAP_SPENT = 'economy.scrap_spent',
XP_EARNED = 'economy.xp_earned',
LEVEL_UP = 'economy.level_up',
// Cases
CASE_OPENED = 'cases.opened',
ITEM_WON = 'cases.item_won',
// Referrals
REFERRAL_REGISTERED = 'referrals.registered',
PASSIVE_INCOME_CLAIMED = 'referrals.passive_income_claimed',
// Quests & Achievements
QUEST_COMPLETED = 'quests.completed',
ACHIEVEMENT_UNLOCKED = 'achievements.unlocked',
// Withdrawals
WITHDRAWAL_REQUESTED = 'withdrawals.requested',
WITHDRAWAL_COMPLETED = 'withdrawals.completed',
WITHDRAWAL_FAILED = 'withdrawals.failed',
// ... и другие
}
Использование
import { logBusinessEvent, BusinessEventType } from '@common/observability';
// Базовое событие
logBusinessEvent(BusinessEventType.CASE_OPENED, {
caseId: 'case-123',
scrapCost: 100,
});
// Economy событие (типизированное)
import { logEconomyEvent } from '@common/observability';
logEconomyEvent(BusinessEventType.SCRAP_EARNED, {
amount: 50,
balance: 1050,
source: 'referral_passive_income',
});
Фильтрация в Loki
{service="goloot-backend"} |= "[BUSINESS]"
{service="goloot-backend"} | json | eventType = "economy.scrap_earned"
{service="goloot-backend"} | json | eventType =~ "withdrawals.*"
5. Prometheus Metrics
Файлы: metrics.ts, metrics.routes.ts
Доступные метрики
| Метрика | Тип | Labels | Описание |
|---|---|---|---|
http_request_duration_seconds | Histogram | method, route, status_code | Latency запросов |
http_requests_total | Counter | method, route, status_code | Количество запросов |
business_events_total | Counter | event_type, domain | Бизнес-события |
economy_transactions_total | Counter | currency, operation, source | Транзакции экономики |
economy_amount_total | Counter | currency, operation, source | Суммы транзакций |
Нормализация роутов
Для предотвращения high cardinality, динамические сегменты заменяются на :id:
/api/users/123e4567-e89b-12d3-a456-426614174000 → /api/users/:id
/api/quests/quest-123/rewards → /api/quests/:id/rewards
Эндпоинт
GET /metrics
Возвращает метрики в формате Prometheus text.
Использование в коде
import { recordBusinessEvent, recordEconomyTransaction } from '@common/observability';
// Записать бизнес-событие
recordBusinessEvent('cases.opened');
// Записать транзакцию
recordEconomyTransaction('scrap', 'earned', 'quest_reward', 100);
6. OpenTelemetry Tracing
Файл: tracing.ts
Распределённый трейсинг с автоматической инструментацией.
Конфигурация
| ENV Variable | Описание | Default |
|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | URL Tempo (OTLP) | — (disabled) |
OTEL_SERVICE_NAME | Имя сервиса | goloot-backend |
Автоинструментация
Включена автоматически для:
- HTTP входящие запросы (Fastify)
- HTTP исходящие запросы (fetch, http)
- Prisma запросы
Исключены:
/health— healthcheck/metrics— Prometheus scrape
Инициализация
Трейсинг должен инициализироваться до любых других импортов!
// backend/src/index.ts — ПЕРВЫЕ СТРОКИ!
import dotenv from 'dotenv';
dotenv.config({ path: '.env.local' });
dotenv.config();
import { initTracing } from '@common/observability/tracing';
initTracing();
// ... остальные импорты
Корреляция с логами
В Grafana настроена корреляция Tempo → Loki:
- Клик на trace → переход к логам с этим
requestId - В логах автоматически добавляется
traceId
7. Traced HTTP Client
Файл: http-client.ts
Обёртка над fetch() с автоматическим логированием и трейсингом.
API
import { tracedFetch, tracedFetchJson, tracedPost } from '@common/observability';
// Базовый fetch
const response = await tracedFetch('https://api.steam.com/user', {
serviceName: 'steam-api',
timeout: 5000,
});
// JSON fetch
const { data, response, duration } = await tracedFetchJson<SteamUser>(
'https://api.steam.com/user',
{ serviceName: 'steam-api' }
);
// POST с JSON body
const result = await tracedPost<CreateResponse>(
'https://api.telegram.org/bot/sendMessage',
{ chat_id: 123, text: 'Hello' },
{ serviceName: 'telegram-bot' }
);
Features
- Автоматическое логирование start/end
- Propagation
X-Request-IDheader - Timeout handling с AbortController
- Sanitization URLs в логах (скрытие api_key, token, etc.)
Infrastructure
Docker Compose
# monitoring/docker-compose.yml
services:
loki:
image: grafana/loki:2.9.0
ports:
- "3100:3100"
prometheus:
image: prom/prometheus:v2.47.0
ports:
- "9090:9090"
tempo:
image: grafana/tempo:2.3.0
ports:
- "3200:3200" # HTTP API
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
grafana:
image: grafana/grafana:10.2.0
ports:
- "3000:3000"
Grafana Datasources
| Source | URL | Назначение |
|---|---|---|
| Loki | http://loki:3100 | Логи |
| Prometheus | http://prometheus:9090 | Метрики |
| Tempo | http://tempo:3200 | Трейсы |
Best Practices
Log Message Format Standard
All log messages should follow the format: [Domain] Action description
| Old Style | New Style |
|---|---|
🎯 ReferralService.createReferral: | [Referral] Creating referral |
✅ Referrer XP reward claimed: | [Referral] Referrer XP reward claimed |
❌ Error claiming reward: | [Referral] Failed to claim reward |
⚠️ Warning message | [Referral] Warning message |
Логирование
-
Используй стандартный формат
[Domain]:// ✅ Правильно
logger.info('[Referral] Creating referral', { referrerId, referredId });
logger.error('[Referral] Failed to create referral', { error: err.message });
// ❌ Неправильно — emoji
logger.info('🎯 Creating referral:', { referrerId });
// ❌ Неправильно — method name style
logger.info('ReferralService.createReferral:', { referrerId }); -
Используй структурированные данные:
// ✅ Правильно
logger.info('[User] User created', { userId, email, source: 'registration' });
// ❌ Неправильно
logger.info(`User ${userId} created with email ${email}`); -
Не логируй sensitive data:
// ❌ Неправильно
logger.info('Auth', { token, password });
// ✅ Правильно
logger.info('Auth successful', { userId, method: 'telegram' }); -
Используй бизнес-события для аналитики:
// Вместо обычного лога
logBusinessEvent(BusinessEventType.SCRAP_EARNED, { amount, source });
Метрики
-
Избегай high cardinality labels:
// ❌ userId как label — миллионы уникальных значений
counter.labels(userId).inc();
// ✅ Только low cardinality
counter.labels(method, route, statusCode).inc(); -
Используй правильные типы метрик:
- Counter — только растущие значения (requests, events)
- Histogram — распределения (latency, sizes)
- Gauge — значения в моменте (active users, queue size)
Трейсинг
-
Инициализируй трейсинг первым:
// ПЕРВЫЕ строки в index.ts!
import { initTracing } from '@common/observability/tracing';
initTracing(); -
Используй tracedFetch для внешних вызовов:
// Автоматически добавляет span и логирует
const response = await tracedFetch(url, { serviceName: 'steam' });
8. Debugging Flow
Пошаговый процесс разбора ошибки от жалобы пользователя до root cause.
Сценарий: Пользователь сообщает об ошибке
"У меня не засчитался квест" / "Не пришла награда" / "Что-то не работает"
Шаг 1: Найти логи пользователя
Через Admin Panel:
- Открыть карточку пользователя в админке
- Нажать кнопку "Логи" → откроется Grafana с предзаполненным запросом
Ручной поиск в Loki:
{container_name=~".*backend.*"} | json | userId = "user-uuid-here"
Если известен только telegramId:
- Найти пользователя в админке по telegramId
- Скопировать его UUID
- Искать по userId в Loki
Шаг 2: Найти проблемный запрос
Отфильтруй по времени, когда произошла ошибка:
- Пользователь обычно помнит примерное время
- Ищи логи с
level="error"илиstatusCode >= 400
{container_name=~".*backend.*"}
| json
| userId = "user-uuid"
| level = "error"
Шаг 3: Получить trace_id
Из найденного лога скопируй trace_id:
{
"msg": "[HTTP] Response",
"statusCode": 500,
"requestId": "abc-123-def",
"trace_id": "4f2e8a1b9c3d..." // ← копируем это
}
Шаг 4: Найти ВСЕ логи запроса
Поиск по trace_id покажет полную картину одного HTTP-запроса:
{container_name=~".*backend.*"} | json | trace_id = "4f2e8a1b9c3d..."
Это покажет:
- Входящий запрос (
[HTTP] Incoming request) - Все промежуточные логи (бизнес-логика, ошибки)
- Исходящие запросы к внешним API
- Финальный ответ (
[HTTP] Response)
Шаг 5: Посмотреть trace в Tempo
Для детального анализа timing:
- Открой Grafana → Explore → Tempo
- Вставь
trace_idв поиск - Увидишь waterfall диаграмму всех операций
Tempo показывает:
- Время каждой операции (Prisma запросы, HTTP вызовы)
- Вложенность операций (span hierarchy)
- Где именно произошла задержка или ошибка
Примеры запросов для типичных проблем
| Проблема | LogQL запрос |
|---|---|
| Все ошибки 500 | {container_name=~".*backend.*"} | json | statusCode = "500" |
| Медленные запросы (>1s) | {container_name=~".*backend.*"} | json | duration > 1000 |
| Ошибки конкретного эндпоинта | {container_name=~".*backend.*"} | json | path = "/api/quests/claim" |
| Бизнес-события пользователя | {container_name=~".*backend.*"} |= "[BUSINESS]" | json | userId = "xxx" |
| Webhook ошибки | {container_name=~".*backend.*"} | json | path =~ "/webhook.*" | level = "error" |
Корреляция: от ошибки к причине
9. TMA Logging Strategy
Стратегия логирования для Telegram Mini App (TMA) endpoints.
Цель
Уметь ответить на вопрос пользователя: "Почему не засчиталось?"
Что логировать
| Категория | Когда | Log Level | Данные |
|---|---|---|---|
| Успех | Бизнес-событие выполнено | logBusinessEvent() | userId, entityId, amount |
| Отказ | Бизнес-правило не выполнено | logger.warn() | userId, entityId, reason |
| Ошибка | Exception/DB error | logger.error() | userId, error.message |
Reason Codes для отказов
Стандартные коды причин для "не засчиталось":
| Reason Code | Описание | Пример |
|---|---|---|
NOT_COMPLETED | Прогресс не достигнут | Quest progress: 5/10 |
ALREADY_CLAIMED | Уже получено | Achievement уже claimed |
COOLDOWN | Не прошёл cooldown | Spin available in 12h |
NOT_FOUND | Сущность не найдена | Quest not found |
INSUFFICIENT_BALANCE | Недостаточно средств | Need 100 scrap, have 50 |
INVALID_STATE | Неверное состояние | Quest not IN_PROGRESS |
SEASON_MISMATCH | Разные сезоны | Referral from old season |
Критичные TMA Endpoints
Это endpoints где пользователь ожидает результат (получить награду, потратить ресурс, изменить состояние) и может спросить "почему не засчиталось?".
Экономика — Траты/Получение ресурсов (12 endpoints):
| Endpoint | Success Event | Failure Reason Codes |
|---|---|---|
POST /cases/:id/open | CASE_OPENED | INSUFFICIENT_BALANCE, NOT_FOUND, COOLDOWN |
POST /daily-spin/:id/spin | SPIN_EXECUTED | COOLDOWN, NOT_FOUND |
POST /inventory/salvage | SALVAGE_EXECUTED | NOT_FOUND, INVALID_STATE |
POST /craft/:id | CRAFT_COMPLETED | INSUFFICIENT_BALANCE, NOT_FOUND |
POST /withdraw | WITHDRAWAL_REQUESTED | NOT_FOUND, INVALID_STATE, INSUFFICIENT_BALANCE |
POST /buffs/:id/activate | BUFF_ACTIVATED | INSUFFICIENT_BALANCE, NOT_FOUND, ALREADY_ACTIVE |
POST /referrals/claim-passive | PASSIVE_INCOME_CLAIMED | COOLDOWN, INSUFFICIENT_BALANCE |
POST /referrals/claim-xp | REFERRER_XP_CLAIMED | ALREADY_CLAIMED, NOT_FOUND |
POST /seasons/claim-reward | SEASON_REWARD_CLAIMED | NOT_COMPLETED, ALREADY_CLAIMED, SEASON_MISMATCH |
POST /leaderboard/claim-reward | LEADERBOARD_REWARD_CLAIMED | NOT_COMPLETED, ALREADY_CLAIMED |
POST /quizzes/:id/submit | QUIZ_COMPLETED | ALREADY_COMPLETED, NOT_FOUND, INVALID_ANSWER |
POST /onboarding/complete | ONBOARDING_COMPLETED | ALREADY_COMPLETED |
Геймификация — Прогресс/Награды (6 endpoints):
| Endpoint | Success Event | Failure Reason Codes |
|---|---|---|
POST /quests/:id/claim | QUEST_REWARD_CLAIMED | NOT_COMPLETED, ALREADY_CLAIMED |
POST /quests/:id/start | QUEST_STARTED | NOT_FOUND, ALREADY_ACTIVE, LIMIT_REACHED |
POST /achievements/:id/claim | ACHIEVEMENT_CLAIMED | NOT_COMPLETED, ALREADY_CLAIMED |
POST /streaks/claim-daily | STREAK_CLAIMED | COOLDOWN, NOT_FOUND |
POST /streaks/claim-milestone | STREAK_MILESTONE_CLAIMED | NOT_COMPLETED, ALREADY_CLAIMED |
POST /rust/quests/:id/start | RUST_QUEST_STARTED | NOT_FOUND, NOT_VERIFIED, ALREADY_ACTIVE |
Социальные — Взаимодействия (4 endpoints):
| Endpoint | Success Event | Failure Reason Codes |
|---|---|---|
POST /raffle/buy-ticket | RAFFLE_TICKET_PURCHASED | INSUFFICIENT_BALANCE, RAFFLE_CLOSED |
POST /promo-codes/redeem | PROMO_CODE_REDEEMED | INVALID_CODE, ALREADY_USED, EXPIRED, LIMIT_REACHED |
POST /feedback/ban-appeal | BAN_APPEAL_SUBMITTED | ALREADY_SUBMITTED, NOT_BANNED |
POST /referrals/activate | REFERRAL_ACTIVATED | ALREADY_ACTIVATED, INVALID_CODE, SELF_REFERRAL |
Итого: ~22 критичных endpoints требуют логирования отказов.
Что НЕ нужно логировать
| Тип | Пример | Почему |
|---|---|---|
| GET endpoints | GET /quests, GET /inventory | Нет бизнес-события, только чтение |
| Уже логируется HTTP | [HTTP] Response 200 | Fastify логирует автоматически |
| Simple CRUD | PUT /settings | Нет "не засчиталось" сценария |
| Internal operations | Cron jobs success | Для метрик, не для debugging |
Паттерн логирования
// В service методе:
// 1. Проверка условий с логированием отказа
if (userQuest.status !== 'COMPLETED') {
logger.warn('[Quest] Claim rejected', {
userId,
questId,
reason: 'NOT_COMPLETED',
current: userQuest.progress,
required: quest.targetValue,
});
throw new BadRequestError('QUEST_NOT_COMPLETED');
}
if (userQuest.rewardClaimed) {
logger.warn('[Quest] Claim rejected', {
userId,
questId,
reason: 'ALREADY_CLAIMED',
});
throw new BadRequestError('ALREADY_CLAIMED');
}
// 2. Выполнение операции
const result = await this.prisma.$transaction(async (tx) => {
// ... бизнес-логика
});
// 3. Логирование успеха
logBusinessEvent(BusinessEventType.QUEST_REWARD_CLAIMED, {
userId,
questId,
rewardType: reward.type,
rewardAmount: reward.amount,
});
return result;
Поиск в Loki
# Все отказы для пользователя
{container_name=~".*backend.*"} | json | userId = "xxx" | reason != ""
# Конкретный тип отказа
{container_name=~".*backend.*"} | json | reason = "NOT_COMPLETED"
# Все claim операции
{container_name=~".*backend.*"} |= "Claim" | json | userId = "xxx"
Migration History
2026-01-22: logger-bridge removed
The deprecated logger-bridge.ts has been removed. All logging should use:
import { logger } from '@common/observability';
What changed:
- 181 files migrated from
@common/utils/logger-bridgeto@common/observability - Log message format standardized to
[Domain] Action description - Emojis removed from log messages
- Test mocks updated to mock
@common/observabilityinstead
If you see old imports:
// ❌ Old (no longer works)
import logger from '@common/utils/logger-bridge';
// ✅ New
import { logger } from '@common/observability';
Связанные документы
- Troubleshooting Guide — operational runbook: от жалобы пользователя до root cause
- Log Architecture — архитектура логирования (инфраструктура)
- Common Structure — структура common/ директории
- Domain Structure — стандарт структуры домена