Skip to content

ADR-008: Логирование и мониторинг

Дата: 2026-01-04
Статус: Принято
Авторы: Backend Team


Контекст

Проект требует системы наблюдаемости (observability) для:

  • Отладки проблем в production
  • Мониторинга производительности
  • Алертинга при сбоях
  • Анализа поведения пользователей
  • Аудита действий (безопасность)

Требования

  • Централизованные логи
  • Метрики приложения и инфраструктуры
  • Трейсинг запросов
  • Dashboards для визуализации
  • Алерты в Telegram/Email

Решение

Стек мониторинга

КомпонентИнструментНазначение
ЛогированиеPino + LokiСтруктурированные логи
МетрикиPrometheusСбор метрик
ВизуализацияGrafanaDashboards, алерты
APM/TracingOpenTelemetryDistributed tracing
UptimeUptime KumaМониторинг доступности

Архитектура

┌─────────────────────────────────────────────────────────────┐
│                     APPLICATION                              │
│  ┌─────────────────────────────────────────────────────────┐│
│  │  Pino Logger    │  OTEL Tracer  │  Prometheus Client    ││
│  └────────┬────────┴───────┬───────┴──────────┬────────────┘│
└───────────┼────────────────┼──────────────────┼─────────────┘
            │                │                  │
            ▼                ▼                  ▼
     ┌──────────┐    ┌──────────────┐    ┌───────────┐
     │   Loki   │    │  Tempo/Jaeger│    │Prometheus │
     │  (logs)  │    │  (traces)    │    │ (metrics) │
     └────┬─────┘    └──────┬───────┘    └─────┬─────┘
          │                 │                  │
          └────────────┬────┴──────────────────┘

              ┌────────────────┐
              │    Grafana     │
              │  (dashboards)  │
              │   (alerts)     │
              └────────────────┘


              ┌────────────────┐
              │   Telegram     │
              │   (alerts)     │
              └────────────────┘

Логирование

Библиотека: Pino

Выбор Pino обусловлен:

  • Высокая производительность (до 5x быстрее Winston)
  • Структурированный JSON-вывод
  • Встроенная поддержка в Medusa.js
  • Маленький размер

Конфигурация логгера

typescript
// src/config/logger.ts
import pino from 'pino';

const isProduction = process.env.NODE_ENV === 'production';

export const logger = pino({
  level: process.env.LOG_LEVEL || (isProduction ? 'info' : 'debug'),
  
  // Форматирование для разработки
  transport: isProduction
    ? undefined
    : {
        target: 'pino-pretty',
        options: {
          colorize: true,
          translateTime: 'SYS:standard',
          ignore: 'pid,hostname',
        },
      },
  
  // Базовые поля для всех логов
  base: {
    service: 'autoparts-api',
    version: process.env.APP_VERSION || '1.0.0',
    env: process.env.NODE_ENV,
  },
  
  // Редактирование чувствительных данных
  redact: {
    paths: [
      'req.headers.authorization',
      'req.headers.cookie',
      'password',
      'token',
      'secret',
      '*.password',
      '*.token',
    ],
    censor: '[REDACTED]',
  },
});

// Child loggers для модулей
export const createLogger = (module: string) => logger.child({ module });

Уровни логирования

УровеньИспользованиеПример
fatalКритическая ошибка, приложение падаетБД недоступна
errorОшибка операцииНе удалось отправить SMS
warnПотенциальная проблемаRate limit близок к лимиту
infoВажные бизнес-событияПользователь зарегистрирован
debugОтладочная информацияSQL запрос, cache hit
traceДетальная трассировкаКаждый шаг алгоритма

Стандарты логирования

typescript
// ✅ ПРАВИЛЬНО: структурированный лог с контекстом
logger.info({
  event: 'user.registered',
  userId: user.id,
  accountType: user.accountType,
  duration: endTime - startTime,
}, 'User registration completed');

// ❌ НЕПРАВИЛЬНО: строковая интерполяция
logger.info(`User ${user.id} registered in ${duration}ms`);

Request logging middleware

typescript
// src/api/middlewares/request-logger.ts
import { pinoHttp } from 'pino-http';
import { logger } from '../config/logger';

export const requestLogger = pinoHttp({
  logger,
  
  // Генерация request ID
  genReqId: (req) => req.headers['x-request-id'] || uuidv4(),
  
  // Кастомизация лога
  customLogLevel: (req, res, err) => {
    if (res.statusCode >= 500) return 'error';
    if (res.statusCode >= 400) return 'warn';
    return 'info';
  },
  
  // Дополнительные поля
  customProps: (req, res) => ({
    userId: req.auth?.userId,
    path: req.path,
    method: req.method,
    statusCode: res.statusCode,
    responseTime: res.getHeader('x-response-time'),
  }),
  
  // Не логировать health check
  autoLogging: {
    ignore: (req) => req.url === '/health',
  },
});

Отправка в Loki (production)

typescript
// src/config/logger.ts (production)
import pino from 'pino';

export const logger = pino({
  level: 'info',
  transport: {
    target: 'pino-loki',
    options: {
      host: process.env.LOKI_HOST, // http://loki:3100
      labels: {
        app: 'autoparts-api',
        env: process.env.NODE_ENV,
      },
      batching: true,
      interval: 5, // секунд
    },
  },
});

Метрики

Prometheus Client

typescript
// src/metrics/index.ts
import { Registry, Counter, Histogram, Gauge, collectDefaultMetrics } from 'prom-client';

export const registry = new Registry();

// Включить стандартные метрики Node.js
collectDefaultMetrics({ register: registry });

// HTTP метрики
export const httpRequestDuration = new Histogram({
  name: 'http_request_duration_seconds',
  help: 'Duration of HTTP requests in seconds',
  labelNames: ['method', 'route', 'status_code'],
  buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
  registers: [registry],
});

export const httpRequestTotal = new Counter({
  name: 'http_requests_total',
  help: 'Total number of HTTP requests',
  labelNames: ['method', 'route', 'status_code'],
  registers: [registry],
});

// Бизнес-метрики
export const productsCreated = new Counter({
  name: 'products_created_total',
  help: 'Total number of products created',
  labelNames: ['category', 'account_type'],
  registers: [registry],
});

export const usersRegistered = new Counter({
  name: 'users_registered_total',
  help: 'Total number of user registrations',
  labelNames: ['account_type'],
  registers: [registry],
});

export const activeUsers = new Gauge({
  name: 'active_users',
  help: 'Number of active users',
  registers: [registry],
});

// Кэш метрики
export const cacheHits = new Counter({
  name: 'cache_hits_total',
  help: 'Number of cache hits',
  labelNames: ['cache_name'],
  registers: [registry],
});

export const cacheMisses = new Counter({
  name: 'cache_misses_total',
  help: 'Number of cache misses',
  labelNames: ['cache_name'],
  registers: [registry],
});

// БД метрики
export const dbQueryDuration = new Histogram({
  name: 'db_query_duration_seconds',
  help: 'Duration of database queries',
  labelNames: ['query_type', 'table'],
  buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1],
  registers: [registry],
});

Middleware для сбора метрик

typescript
// src/api/middlewares/metrics.ts
import { httpRequestDuration, httpRequestTotal } from '../metrics';

export function metricsMiddleware(req: Request, res: Response, next: NextFunction) {
  const start = process.hrtime();
  
  res.on('finish', () => {
    const [seconds, nanoseconds] = process.hrtime(start);
    const duration = seconds + nanoseconds / 1e9;
    
    const route = req.route?.path || req.path;
    const labels = {
      method: req.method,
      route: route,
      status_code: res.statusCode.toString(),
    };
    
    httpRequestDuration.observe(labels, duration);
    httpRequestTotal.inc(labels);
  });
  
  next();
}

Endpoint для Prometheus

typescript
// src/api/admin/metrics/route.ts
import { registry } from '../../../metrics';

export async function GET(req: MedusaRequest, res: MedusaResponse) {
  res.setHeader('Content-Type', registry.contentType);
  res.send(await registry.metrics());
}

Distributed Tracing

OpenTelemetry setup

typescript
// src/tracing.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';

const sdk = new NodeSDK({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: 'autoparts-api',
    [SemanticResourceAttributes.SERVICE_VERSION]: process.env.APP_VERSION,
    [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV,
  }),
  
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, // http://tempo:4318/v1/traces
  }),
  
  instrumentations: [
    getNodeAutoInstrumentations({
      '@opentelemetry/instrumentation-http': {
        ignoreIncomingRequestHook: (req) => req.url === '/health',
      },
      '@opentelemetry/instrumentation-pg': { enabled: true },
      '@opentelemetry/instrumentation-redis': { enabled: true },
    }),
  ],
});

sdk.start();

process.on('SIGTERM', () => {
  sdk.shutdown().then(() => process.exit(0));
});

Кастомные spans

typescript
import { trace, SpanStatusCode } from '@opentelemetry/api';

const tracer = trace.getTracer('autoparts-api');

async function processOrder(orderId: string) {
  return tracer.startActiveSpan('processOrder', async (span) => {
    try {
      span.setAttribute('order.id', orderId);
      
      // Вложенный span
      await tracer.startActiveSpan('validateOrder', async (validateSpan) => {
        await validateOrder(orderId);
        validateSpan.end();
      });
      
      await tracer.startActiveSpan('chargePayment', async (paymentSpan) => {
        await chargePayment(orderId);
        paymentSpan.end();
      });
      
      span.setStatus({ code: SpanStatusCode.OK });
    } catch (error) {
      span.setStatus({
        code: SpanStatusCode.ERROR,
        message: error.message,
      });
      span.recordException(error);
      throw error;
    } finally {
      span.end();
    }
  });
}

Health Checks

typescript
// src/api/health/route.ts
import { Pool } from 'pg';
import Redis from 'ioredis';

interface HealthStatus {
  status: 'healthy' | 'degraded' | 'unhealthy';
  timestamp: string;
  version: string;
  checks: {
    database: CheckResult;
    redis: CheckResult;
    s3: CheckResult;
  };
}

interface CheckResult {
  status: 'pass' | 'fail';
  latency?: number;
  error?: string;
}

export async function GET(req: MedusaRequest, res: MedusaResponse) {
  const health: HealthStatus = {
    status: 'healthy',
    timestamp: new Date().toISOString(),
    version: process.env.APP_VERSION || '1.0.0',
    checks: {
      database: await checkDatabase(),
      redis: await checkRedis(),
      s3: await checkS3(),
    },
  };
  
  // Определить общий статус
  const failedChecks = Object.values(health.checks).filter(c => c.status === 'fail');
  if (failedChecks.length > 0) {
    health.status = failedChecks.length === Object.keys(health.checks).length 
      ? 'unhealthy' 
      : 'degraded';
  }
  
  const statusCode = health.status === 'healthy' ? 200 : 
                     health.status === 'degraded' ? 200 : 503;
  
  res.status(statusCode).json(health);
}

async function checkDatabase(): Promise<CheckResult> {
  const start = Date.now();
  try {
    await pool.query('SELECT 1');
    return { status: 'pass', latency: Date.now() - start };
  } catch (error) {
    return { status: 'fail', error: error.message };
  }
}

async function checkRedis(): Promise<CheckResult> {
  const start = Date.now();
  try {
    await redis.ping();
    return { status: 'pass', latency: Date.now() - start };
  } catch (error) {
    return { status: 'fail', error: error.message };
  }
}

Grafana Dashboards

API Overview Dashboard

json
{
  "title": "AutoParts API",
  "panels": [
    {
      "title": "Request Rate",
      "type": "graph",
      "targets": [{
        "expr": "sum(rate(http_requests_total[5m])) by (status_code)"
      }]
    },
    {
      "title": "Response Time (p95)",
      "type": "graph",
      "targets": [{
        "expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route))"
      }]
    },
    {
      "title": "Error Rate",
      "type": "stat",
      "targets": [{
        "expr": "sum(rate(http_requests_total{status_code=~\"5..\"}[5m])) / sum(rate(http_requests_total[5m])) * 100"
      }]
    },
    {
      "title": "Active Users",
      "type": "gauge",
      "targets": [{
        "expr": "active_users"
      }]
    }
  ]
}

Алерты

yaml
# alerting-rules.yml
groups:
  - name: autoparts-api
    rules:
      - alert: HighErrorRate
        expr: |
          sum(rate(http_requests_total{status_code=~"5.."}[5m])) 
          / sum(rate(http_requests_total[5m])) > 0.05
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "High error rate ({{ $value | humanizePercentage }})"
          
      - alert: SlowResponses
        expr: |
          histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le)) > 2
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "95th percentile response time is {{ $value }}s"
          
      - alert: DatabaseConnectionHigh
        expr: pg_stat_activity_count > 80
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Database connections: {{ $value }}/100"
          
      - alert: RedisMemoryHigh
        expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.8
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Redis memory usage: {{ $value | humanizePercentage }}"

Telegram alerting

yaml
# alertmanager.yml
receivers:
  - name: telegram
    telegram_configs:
      - bot_token: ${TELEGRAM_BOT_TOKEN}
        chat_id: ${TELEGRAM_CHAT_ID}
        message: |
          🚨 *{{ .Status | toUpper }}*
          *Alert:* {{ .CommonAnnotations.summary }}
          *Severity:* {{ .CommonLabels.severity }}
          *Time:* {{ .StartsAt.Format "15:04:05 MST" }}

Audit Logging

typescript
// src/services/audit.service.ts
interface AuditLog {
  timestamp: Date;
  userId: string | null;
  action: string;
  resource: string;
  resourceId: string;
  changes?: Record<string, { old: any; new: any }>;
  ip: string;
  userAgent: string;
}

export class AuditService {
  private logger = createLogger('audit');
  
  async log(event: AuditLog): Promise<void> {
    // 1. Запись в лог
    this.logger.info({
      event: 'audit',
      ...event,
    });
    
    // 2. Сохранение в БД (для поиска)
    await this.auditRepository.insert(event);
  }
  
  // Примеры использования
  async logUserLogin(userId: string, req: Request): Promise<void> {
    await this.log({
      timestamp: new Date(),
      userId,
      action: 'login',
      resource: 'session',
      resourceId: userId,
      ip: req.ip,
      userAgent: req.headers['user-agent'],
    });
  }
  
  async logProductUpdate(
    userId: string,
    productId: string,
    changes: Record<string, any>,
    req: Request
  ): Promise<void> {
    await this.log({
      timestamp: new Date(),
      userId,
      action: 'update',
      resource: 'product',
      resourceId: productId,
      changes,
      ip: req.ip,
      userAgent: req.headers['user-agent'],
    });
  }
}

Последствия

Положительные

  • Observability: полная видимость системы
  • Отладка: быстрый поиск причин проблем
  • Проактивность: алерты до влияния на пользователей
  • Безопасность: аудит действий

Отрицательные

  • Сложность: дополнительная инфраструктура
  • Стоимость: хранение логов и метрик
  • Производительность: overhead на сбор данных

Митигация

ПроблемаРешение
Много логовRetention policy, sampling
OverheadAsync logging, batching
СтоимостьSelf-hosted Loki/Prometheus

Связанные решения