Appearance
ADR-008: Логирование и мониторинг
Дата: 2026-01-04
Статус: Принято
Авторы: Backend Team
Контекст
Проект требует системы наблюдаемости (observability) для:
- Отладки проблем в production
- Мониторинга производительности
- Алертинга при сбоях
- Анализа поведения пользователей
- Аудита действий (безопасность)
Требования
- Централизованные логи
- Метрики приложения и инфраструктуры
- Трейсинг запросов
- Dashboards для визуализации
- Алерты в Telegram/Email
Решение
Стек мониторинга
| Компонент | Инструмент | Назначение |
|---|---|---|
| Логирование | Pino + Loki | Структурированные логи |
| Метрики | Prometheus | Сбор метрик |
| Визуализация | Grafana | Dashboards, алерты |
| APM/Tracing | OpenTelemetry | Distributed tracing |
| Uptime | Uptime 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 |
| Overhead | Async logging, batching |
| Стоимость | Self-hosted Loki/Prometheus |
Связанные решения
- ADR-005: Кэширование — метрики кэша
- ADR-007: Background Jobs — мониторинг очередей