Skip to main content

Observability

Система наблюдаемости (observability) для мониторинга, логирования и трейсинга.

Overview

Модуль backend/src/common/observability/ предоставляет три столпа observability:

КомпонентИнструментНазначение
LogsPino → LokiСтруктурированные логи с контекстом запроса
Metricsprom-client → PrometheusЧисловые метрики (latency, counters)
TracesOpenTelemetry → 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 использует числовые уровни логирования:

LevelNumberОписание
trace10Очень детальная отладка
debug20Debug информация
info30Нормальные операции
warn40Предупреждения
error50Ошибки
fatal60Критические ошибки

Конфигурация:

ENV VariableОписаниеDefault
LOG_LEVELМинимальный уровень логовinfo
LOG_PRETTYЧеловекочитаемый формат (dev)false
LOG_LEVEL контролирует что логируется

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_secondsHistogrammethod, route, status_codeLatency запросов
http_requests_totalCountermethod, route, status_codeКоличество запросов
business_events_totalCounterevent_type, domainБизнес-события
economy_transactions_totalCountercurrency, operation, sourceТранзакции экономики
economy_amount_totalCountercurrency, 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_ENDPOINTURL 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-ID header
  • 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

SourceURLНазначение
Lokihttp://loki:3100Логи
Prometheushttp://prometheus:9090Метрики
Tempohttp://tempo:3200Трейсы

Best Practices

Log Message Format Standard

All log messages should follow the format: [Domain] Action description

Old StyleNew 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

Логирование

  1. Используй стандартный формат [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 });
  2. Используй структурированные данные:

    // ✅ Правильно
    logger.info('[User] User created', { userId, email, source: 'registration' });

    // ❌ Неправильно
    logger.info(`User ${userId} created with email ${email}`);
  3. Не логируй sensitive data:

    // ❌ Неправильно
    logger.info('Auth', { token, password });

    // ✅ Правильно
    logger.info('Auth successful', { userId, method: 'telegram' });
  4. Используй бизнес-события для аналитики:

    // Вместо обычного лога
    logBusinessEvent(BusinessEventType.SCRAP_EARNED, { amount, source });

Метрики

  1. Избегай high cardinality labels:

    // ❌ userId как label — миллионы уникальных значений
    counter.labels(userId).inc();

    // ✅ Только low cardinality
    counter.labels(method, route, statusCode).inc();
  2. Используй правильные типы метрик:

    • Counter — только растущие значения (requests, events)
    • Histogram — распределения (latency, sizes)
    • Gauge — значения в моменте (active users, queue size)

Трейсинг

  1. Инициализируй трейсинг первым:

    // ПЕРВЫЕ строки в index.ts!
    import { initTracing } from '@common/observability/tracing';
    initTracing();
  2. Используй tracedFetch для внешних вызовов:

    // Автоматически добавляет span и логирует
    const response = await tracedFetch(url, { serviceName: 'steam' });

8. Debugging Flow

Пошаговый процесс разбора ошибки от жалобы пользователя до root cause.

Сценарий: Пользователь сообщает об ошибке

"У меня не засчитался квест" / "Не пришла награда" / "Что-то не работает"

Шаг 1: Найти логи пользователя

Через Admin Panel:

  1. Открыть карточку пользователя в админке
  2. Нажать кнопку "Логи" → откроется Grafana с предзаполненным запросом

Ручной поиск в Loki:

{container_name=~".*backend.*"} | json | userId = "user-uuid-here"
Поиск по telegramId

Если известен только telegramId:

  1. Найти пользователя в админке по telegramId
  2. Скопировать его UUID
  3. Искать по 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:

  1. Открой Grafana → Explore → Tempo
  2. Вставь trace_id в поиск
  3. Увидишь 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 errorlogger.error()userId, error.message

Reason Codes для отказов

Стандартные коды причин для "не засчиталось":

Reason CodeОписаниеПример
NOT_COMPLETEDПрогресс не достигнутQuest progress: 5/10
ALREADY_CLAIMEDУже полученоAchievement уже claimed
COOLDOWNНе прошёл cooldownSpin 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?

Это endpoints где пользователь ожидает результат (получить награду, потратить ресурс, изменить состояние) и может спросить "почему не засчиталось?".

Экономика — Траты/Получение ресурсов (12 endpoints):

EndpointSuccess EventFailure Reason Codes
POST /cases/:id/openCASE_OPENEDINSUFFICIENT_BALANCE, NOT_FOUND, COOLDOWN
POST /daily-spin/:id/spinSPIN_EXECUTEDCOOLDOWN, NOT_FOUND
POST /inventory/salvageSALVAGE_EXECUTEDNOT_FOUND, INVALID_STATE
POST /craft/:idCRAFT_COMPLETEDINSUFFICIENT_BALANCE, NOT_FOUND
POST /withdrawWITHDRAWAL_REQUESTEDNOT_FOUND, INVALID_STATE, INSUFFICIENT_BALANCE
POST /buffs/:id/activateBUFF_ACTIVATEDINSUFFICIENT_BALANCE, NOT_FOUND, ALREADY_ACTIVE
POST /referrals/claim-passivePASSIVE_INCOME_CLAIMEDCOOLDOWN, INSUFFICIENT_BALANCE
POST /referrals/claim-xpREFERRER_XP_CLAIMEDALREADY_CLAIMED, NOT_FOUND
POST /seasons/claim-rewardSEASON_REWARD_CLAIMEDNOT_COMPLETED, ALREADY_CLAIMED, SEASON_MISMATCH
POST /leaderboard/claim-rewardLEADERBOARD_REWARD_CLAIMEDNOT_COMPLETED, ALREADY_CLAIMED
POST /quizzes/:id/submitQUIZ_COMPLETEDALREADY_COMPLETED, NOT_FOUND, INVALID_ANSWER
POST /onboarding/completeONBOARDING_COMPLETEDALREADY_COMPLETED

Геймификация — Прогресс/Награды (6 endpoints):

EndpointSuccess EventFailure Reason Codes
POST /quests/:id/claimQUEST_REWARD_CLAIMEDNOT_COMPLETED, ALREADY_CLAIMED
POST /quests/:id/startQUEST_STARTEDNOT_FOUND, ALREADY_ACTIVE, LIMIT_REACHED
POST /achievements/:id/claimACHIEVEMENT_CLAIMEDNOT_COMPLETED, ALREADY_CLAIMED
POST /streaks/claim-dailySTREAK_CLAIMEDCOOLDOWN, NOT_FOUND
POST /streaks/claim-milestoneSTREAK_MILESTONE_CLAIMEDNOT_COMPLETED, ALREADY_CLAIMED
POST /rust/quests/:id/startRUST_QUEST_STARTEDNOT_FOUND, NOT_VERIFIED, ALREADY_ACTIVE

Социальные — Взаимодействия (4 endpoints):

EndpointSuccess EventFailure Reason Codes
POST /raffle/buy-ticketRAFFLE_TICKET_PURCHASEDINSUFFICIENT_BALANCE, RAFFLE_CLOSED
POST /promo-codes/redeemPROMO_CODE_REDEEMEDINVALID_CODE, ALREADY_USED, EXPIRED, LIMIT_REACHED
POST /feedback/ban-appealBAN_APPEAL_SUBMITTEDALREADY_SUBMITTED, NOT_BANNED
POST /referrals/activateREFERRAL_ACTIVATEDALREADY_ACTIVATED, INVALID_CODE, SELF_REFERRAL

Итого: ~22 критичных endpoints требуют логирования отказов.

Что НЕ нужно логировать

ТипПримерПочему
GET endpointsGET /quests, GET /inventoryНет бизнес-события, только чтение
Уже логируется HTTP[HTTP] Response 200Fastify логирует автоматически
Simple CRUDPUT /settingsНет "не засчиталось" сценария
Internal operationsCron 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-bridge to @common/observability
  • Log message format standardized to [Domain] Action description
  • Emojis removed from log messages
  • Test mocks updated to mock @common/observability instead

If you see old imports:

// ❌ Old (no longer works)
import logger from '@common/utils/logger-bridge';

// ✅ New
import { logger } from '@common/observability';

Связанные документы