Observability API: логи, метрики, трейсы с OpenTelemetry

← Назад к блогу

📑 Содержание

TL;DR: Observability — способность понять, что происходит внутри вашего API, через три сигнала: логи (события), метрики (числа) и трейсы (путь запроса). OpenTelemetry — open-source стандарт для сбора всей телеметрии. Он vendor-neutral: данные отправляются в Jaeger, Grafana, Datadog или любой другой бэкенд.

1. Observability vs Мониторинг

Мониторинг отвечает на вопрос «работает ли система?» — вы заранее определяете, что проверять (uptime, CPU, error rate). Observability отвечает на вопрос «почему система сломалась?» — вы можете исследовать любую проблему, даже ту, которую не предвидели.

Аспект Мониторинг Observability
Вопрос «Работает ли?» «Почему не работает?»
Подход Заранее определённые проверки Исследование неизвестных проблем
Данные Метрики, healthcheck Логи + метрики + трейсы
Пример «Error rate > 5% — алерт» «Почему запрос от user-123 занял 12 секунд?»
Архитектура Достаточно для монолита Критично для микросервисов
⚠️ Без observability в микросервисах: Один API-запрос проходит через Gateway → Auth Service → User Service → Database → Cache. Если ответ занял 5 секунд, без distributed tracing невозможно понять, какой сервис тормозит. Подробнее о дебаге API-ошибок — в нашей статье «Чеклист отладки API».

2. Три столпа: логи, метрики, трейсы

📝

Логи

Дискретные события с временной меткой. Что произошло и когда.

📊

Метрики

Числовые измерения. Сколько, как быстро, какой процент.

🔗

Трейсы

Путь запроса через систему. Где задержка, что вызвало ошибку.

📝 Логи (Logs)

Logs

Лог — запись о событии в определённый момент времени. Structured logs (JSON) предпочтительнее plain text для автоматического анализа.

// Structured log (хорошо)
{
  "timestamp": "2026-02-23T14:30:00Z",
  "level": "error",
  "message": "Failed to fetch user",
  "service": "user-service",
  "trace_id": "abc123def456",
  "span_id": "789ghi",
  "user_id": "user-42",
  "http_status": 500,
  "duration_ms": 1523,
  "error": "Connection refused: postgres:5432"
}

📊 Метрики (Metrics)

Metrics

Метрика — числовое значение, агрегированное за интервал. Четыре типа:

🔗 Трейсы (Traces)

Traces

Трейс — полный путь запроса через все сервисы. Состоит из spans (отрезков), связанных trace_id:

API Gateway
342ms
Auth Service
45ms
User Service
230ms
PostgreSQL
180ms
Redis Cache
3ms

Распределённый трейс: один запрос → 5 spans в 4 сервисах. PostgreSQL — узкое место (180ms).

3. Что такое OpenTelemetry

OpenTelemetry (OTel) — open-source проект CNCF, стандарт для сбора телеметрии. Объединяет OpenTracing + OpenCensus.

Ваш код
SDK + auto-instrumentation
OTel Collector
приём, обработка
Бэкенд
Jaeger / Grafana / Datadog

Ключевые компоненты

Компонент Описание
SDK Библиотека для инструментирования кода (Node.js, Python, Go, Java, .NET, PHP, Rust)
Auto-instrumentation Автоматический сбор телеметрии из популярных библиотек (Express, Prisma, pg, fetch)
OTLP OpenTelemetry Protocol — единый протокол передачи данных (gRPC / HTTP)
Collector Промежуточный агент: приём → обработка → экспорт в бэкенды
Exporters Отправка данных в Jaeger, Prometheus, Grafana Tempo, Datadog, New Relic и др.
💡 Vendor-neutral: OpenTelemetry не привязывает вас к конкретному поставщику. Инструментируете код один раз, а бэкенд меняете без изменения кода. Сегодня — Jaeger, завтра — Datadog, через год — Grafana Cloud.

4. OTel для Node.js (Express/Fastify)

Установка

npm install @opentelemetry/sdk-node \
  @opentelemetry/api \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/exporter-metrics-otlp-http \
  @opentelemetry/resources \
  @opentelemetry/semantic-conventions

Инициализация (загрузить до остального кода)

// tracing.ts — импортировать ПЕРВЫМ
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { Resource } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';

const sdk = new NodeSDK({
  resource: new Resource({
    [ATTR_SERVICE_NAME]: 'user-service',
    [ATTR_SERVICE_VERSION]: '1.2.0',
    environment: process.env.NODE_ENV ?? 'development',
  }),

  traceExporter: new OTLPTraceExporter({
    url: 'http://otel-collector:4318/v1/traces',
  }),

  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: 'http://otel-collector:4318/v1/metrics',
    }),
    exportIntervalMillis: 15000,
  }),

  instrumentations: [
    getNodeAutoInstrumentations({
      '@opentelemetry/instrumentation-http': { enabled: true },
      '@opentelemetry/instrumentation-express': { enabled: true },
      '@opentelemetry/instrumentation-pg': { enabled: true },
      '@opentelemetry/instrumentation-redis': { enabled: true },
    }),
  ],
});

sdk.start();
console.log('OpenTelemetry initialized');

process.on('SIGTERM', () => sdk.shutdown());

Кастомные spans

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

const tracer = trace.getTracer('user-service');

async function getUser(userId: string) {
  return tracer.startActiveSpan('getUser', async (span) => {
    try {
      span.setAttribute('user.id', userId);

      const user = await db.user.findUnique({ where: { id: userId } });

      if (!user) {
        span.setStatus({ code: SpanStatusCode.ERROR, message: 'User not found' });
        span.setAttribute('error.type', 'NOT_FOUND');
        throw new TRPCError({ code: 'NOT_FOUND' });
      }

      span.setAttribute('user.role', user.role);
      span.setStatus({ code: SpanStatusCode.OK });
      return user;
    } catch (error) {
      span.recordException(error as Error);
      span.setStatus({ code: SpanStatusCode.ERROR });
      throw error;
    } finally {
      span.end();
    }
  });
}

Кастомные метрики

import { metrics } from '@opentelemetry/api';

const meter = metrics.getMeter('user-service');

const requestCounter = meter.createCounter('api.requests.total', {
  description: 'Total API requests',
});

const requestDuration = meter.createHistogram('api.request.duration', {
  description: 'API request duration in ms',
  unit: 'ms',
});

const activeUsers = meter.createUpDownCounter('api.active_users', {
  description: 'Currently active users',
});

// Express middleware
app.use((req, res, next) => {
  const start = Date.now();

  res.on('finish', () => {
    const duration = Date.now() - start;
    const labels = {
      method: req.method,
      path: req.route?.path ?? req.path,
      status: res.statusCode.toString(),
    };

    requestCounter.add(1, labels);
    requestDuration.record(duration, labels);
  });

  next();
});

5. OTel для Python (FastAPI/Django)

pip install opentelemetry-api \
  opentelemetry-sdk \
  opentelemetry-instrumentation-fastapi \
  opentelemetry-instrumentation-sqlalchemy \
  opentelemetry-instrumentation-redis \
  opentelemetry-exporter-otlp
# tracing.py
from opentelemetry import trace, metrics
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor

resource = Resource.create({
    "service.name": "order-service",
    "service.version": "2.1.0",
})

# Traces
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter(endpoint="otel-collector:4317"))
)
trace.set_tracer_provider(tracer_provider)

# Metrics
metric_reader = PeriodicExportingMetricReader(
    OTLPMetricExporter(endpoint="otel-collector:4317"),
    export_interval_millis=15000,
)
metrics.set_meter_provider(MeterProvider(resource=resource, metric_readers=[metric_reader]))

# Auto-instrumentation
FastAPIInstrumentor.instrument_app(app)
SQLAlchemyInstrumentor().instrument(engine=engine)
# Кастомные spans в FastAPI
from opentelemetry import trace

tracer = trace.get_tracer("order-service")

@app.post("/orders")
async def create_order(order: OrderCreate):
    with tracer.start_as_current_span("create_order") as span:
        span.set_attribute("order.items_count", len(order.items))
        span.set_attribute("order.total", order.total)

        with tracer.start_as_current_span("validate_inventory"):
            await check_inventory(order.items)

        with tracer.start_as_current_span("process_payment"):
            payment = await charge_card(order.payment_method, order.total)
            span.set_attribute("payment.id", payment.id)

        with tracer.start_as_current_span("save_order"):
            db_order = await save_to_db(order)

        return db_order

6. OTel для Go

go get go.opentelemetry.io/otel \
  go.opentelemetry.io/otel/sdk \
  go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp \
  go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
package main

import (
    "context"
    "log"
    "net/http"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

func initTracer() (*sdktrace.TracerProvider, error) {
    exporter, err := otlptracehttp.New(context.Background(),
        otlptracehttp.WithEndpoint("otel-collector:4318"),
        otlptracehttp.WithInsecure(),
    )
    if err != nil {
        return nil, err
    }

    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceNameKey.String("payment-service"),
            semconv.ServiceVersionKey.String("3.0.1"),
        )),
    )

    otel.SetTracerProvider(tp)
    return tp, nil
}

func main() {
    tp, _ := initTracer()
    defer tp.Shutdown(context.Background())

    mux := http.NewServeMux()
    mux.HandleFunc("/payments", handlePayment)

    handler := otelhttp.NewHandler(mux, "payment-server")
    log.Fatal(http.ListenAndServe(":8080", handler))
}

func handlePayment(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    tracer := otel.Tracer("payment-service")

    ctx, span := tracer.Start(ctx, "processPayment")
    defer span.End()

    span.SetAttributes(
        attribute.String("payment.method", "card"),
        attribute.Float64("payment.amount", 99.99),
    )

    // Вложенный span для обращения к внешнему API
    ctx, dbSpan := tracer.Start(ctx, "saveToDatabase")
    // ... save ...
    dbSpan.End()
}

7. Distributed Tracing на практике

Distributed tracing — главная суперсила observability. Один trace_id связывает все сервисы, через которые прошёл запрос.

Как работает propagation

Клиент
создаёт trace_id
Gateway
W3C traceparent
Service A
создаёт child span
Service B
продолжает trace

Контекст трейса передаётся через HTTP-заголовок traceparent (W3C Trace Context):

# W3C Trace Context заголовок
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
#            ^^-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^-^^^^^^^^^^^^^^^^-^^
#         version         trace-id                  span-id     flags

Пример: отладка медленного запроса

Пользователь жалуется: «Страница грузится 8 секунд». Открываем трейс в Jaeger:

GET /api/feed
8200ms
Auth middleware
12ms
fetchPosts
650ms
fetchRecommendations
7400ms ⚠️
→ ML Service
7200ms ⚠️
Redis cache
2ms

Виновник найден за 30 секунд: ML Service отвечает 7.2 секунды. Без трейсинга поиск занял бы часы.

8. Ключевые метрики API

Четыре золотых сигнала (Google SRE)

Latency

Время ответа: p50, p95, p99

📈

Traffic

Запросов в секунду (RPS)

Errors

% ошибок (4xx, 5xx)

RED метрики для API

Метрика Prometheus query Описание Целевое значение
Rate rate(http_requests_total[5m]) Запросов в секунду Зависит от нагрузки
Errors rate(http_requests_total{status=~"5.."}[5m]) Ошибки в секунду < 0.1% от Rate
Duration p50 histogram_quantile(0.5, rate(http_request_duration_seconds_bucket[5m])) Медиана времени ответа < 100ms
Duration p95 histogram_quantile(0.95, ...) 95-й перцентиль < 500ms
Duration p99 histogram_quantile(0.99, ...) 99-й перцентиль < 1s
Saturation node_cpu_seconds_total Загрузка CPU/RAM < 80%
Apdex Кастомная формула Удовлетворённость пользователей > 0.95
💡 SLI / SLO / SLA:
SLI (Service Level Indicator) — измеряемая метрика (p99 latency = 230ms).
SLO (Service Level Objective) — цель (p99 latency < 500ms, 99.9% запросов).
SLA (Service Level Agreement) — договор с последствиями (если SLO нарушен → компенсация).
Подробнее о HTTP-статусах и их влиянии на error rate.

9. Structured Logging

Structured logging — логи в JSON-формате вместо plain text. Каждая запись содержит ключ-значение пары для автоматического поиска и анализа.

Node.js (pino + OpenTelemetry)

import pino from 'pino';
import { trace, context } from '@opentelemetry/api';

const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  formatters: {
    log(object) {
      const span = trace.getSpan(context.active());
      if (span) {
        const { traceId, spanId } = span.spanContext();
        return { ...object, trace_id: traceId, span_id: spanId };
      }
      return object;
    },
  },
});

// Использование
app.get('/users/:id', async (req, res) => {
  logger.info({ userId: req.params.id, method: req.method }, 'Fetching user');

  try {
    const user = await getUser(req.params.id);
    logger.info({ userId: user.id, role: user.role }, 'User found');
    res.json(user);
  } catch (error) {
    logger.error({ userId: req.params.id, err: error }, 'Failed to fetch user');
    res.status(500).json({ error: 'Internal error' });
  }
});

Python (structlog + OpenTelemetry)

import structlog
from opentelemetry import trace

structlog.configure(
    processors=[
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.add_log_level,
        add_trace_context,  # кастомный процессор
        structlog.processors.JSONRenderer(),
    ],
)

def add_trace_context(logger, method_name, event_dict):
    span = trace.get_current_span()
    if span.is_recording():
        ctx = span.get_span_context()
        event_dict["trace_id"] = format(ctx.trace_id, '032x')
        event_dict["span_id"] = format(ctx.span_id, '016x')
    return event_dict

log = structlog.get_logger()

@app.post("/orders")
async def create_order(order: OrderCreate):
    log.info("Creating order", items_count=len(order.items), total=order.total)
    # ...
    log.info("Order created", order_id=db_order.id)
🚫 Что НЕ логировать:

10. Бэкенды: Jaeger, Grafana, Prometheus

OTel Collector — центральный хаб

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 5s
    send_batch_size: 1024
  memory_limiter:
    check_interval: 1s
    limit_mib: 512

exporters:
  # Трейсы → Jaeger
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true

  # Метрики → Prometheus
  prometheus:
    endpoint: 0.0.0.0:8889

  # Логи → Loki
  loki:
    endpoint: http://loki:3100/loki/api/v1/push

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp/jaeger]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [prometheus]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [loki]

Docker Compose — полный стек

# docker-compose.observability.yml
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    volumes:
      - ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml
    ports:
      - "4317:4317"   # OTLP gRPC
      - "4318:4318"   # OTLP HTTP
      - "8889:8889"   # Prometheus metrics

  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686" # UI
      - "4317"        # OTLP gRPC

  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    ports:
      - "9090:9090"

  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true

  loki:
    image: grafana/loki:latest
    ports:
      - "3100:3100"
Бэкенд Сигнал Тип Когда использовать
Jaeger Трейсы Open-source Distributed tracing, поиск узких мест
Grafana Tempo Трейсы Open-source Трейсы + интеграция с Grafana dashboards
Prometheus Метрики Open-source Метрики, алерты, PromQL запросы
Grafana Визуализация Open-source Дашборды для метрик, логов, трейсов
Loki Логи Open-source Логи с label-based поиском (как Prometheus)
Datadog Всё SaaS All-in-one: трейсы + метрики + логи + APM
Grafana Cloud Всё SaaS Managed Grafana + Tempo + Mimir + Loki

11. Алертинг: правила и каналы

Observability без алертов — как пожарная сигнализация без звука. Настройте правила для критичных метрик.

Prometheus alerting rules

# alerts.yml
groups:
  - name: api-alerts
    rules:
      - alert: HighErrorRate
        expr: |
          sum(rate(http_requests_total{status=~"5.."}[5m]))
          /
          sum(rate(http_requests_total[5m]))
          > 0.05
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "API error rate > 5%"
          description: "{ $value | humanizePercentage } ошибок за последние 5 минут"

      - alert: HighLatency
        expr: |
          histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m])) > 2
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "API p99 latency > 2s"

      - alert: ServiceDown
        expr: up == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Service { $labels.instance } is down"
Error budget: Если ваш SLO — 99.9% availability, за месяц допустимо ~43 минуты downtime. Алерт при 50% потраченного бюджета: 1 - (sum(rate(http_requests_total{status!~"5.."}[30d])) / sum(rate(http_requests_total[30d]))) > 0.0005

12. Best practices

1. Инструментируйте на уровне middleware

// Один middleware = покрытие всех эндпоинтов
app.use((req, res, next) => {
  const span = tracer.startSpan(`${req.method} ${req.path}`);
  span.setAttribute('http.method', req.method);
  span.setAttribute('http.url', req.originalUrl);
  span.setAttribute('http.user_agent', req.get('user-agent'));

  res.on('finish', () => {
    span.setAttribute('http.status_code', res.statusCode);
    if (res.statusCode >= 500) {
      span.setStatus({ code: SpanStatusCode.ERROR });
    }
    span.end();
  });

  next();
});

2. Используйте Semantic Conventions

OpenTelemetry определяет стандартные имена атрибутов:

Атрибут Пример значения
http.request.methodGET, POST, PUT
http.response.status_code200, 404, 500
url.path/api/users/123
server.addressapi.example.com
db.systempostgresql, redis
db.statementSELECT * FROM users WHERE id = $1

3. Sampling для production

// 100% трейсов в dev, 10% в production
const sampler = process.env.NODE_ENV === 'production'
  ? new TraceIdRatioBasedSampler(0.1)  // 10%
  : new AlwaysOnSampler();

// Или умный sampling: всегда трейсить ошибки
const sampler = new ParentBasedSampler({
  root: new TraceIdRatioBasedSampler(0.1),
  // Ошибки и медленные запросы — всегда
});

4. Корреляция логов и трейсов

💡 trace_id в каждом логе: Когда лог содержит trace_id, вы можете перейти из лога в Jaeger/Tempo и увидеть полный трейс. И наоборот — из трейса найти все логи запроса в Loki. Это называется корреляция сигналов.

5. Dashboards — стартовый набор

FAQ

Чем observability отличается от мониторинга?

Мониторинг проверяет заранее известные метрики («работает ли?»). Observability позволяет исследовать неизвестные проблемы («почему не работает?»). Мониторинг — подмножество observability. Для микросервисов одного мониторинга недостаточно.

Что такое OpenTelemetry?

OpenTelemetry (OTel) — open-source стандарт CNCF для сбора телеметрии: трейсов, метрик и логов. Vendor-neutral: инструментируете код один раз, данные отправляете в любой бэкенд (Jaeger, Datadog, Grafana).

Какие метрики API отслеживать в первую очередь?

Четыре золотых сигнала Google SRE: Latency (p50/p95/p99), Traffic (RPS), Errors (% 5xx), Saturation (CPU/RAM). Дополнительно: apdex, SLO compliance, per-endpoint breakdown.

Нужен ли OpenTelemetry для одного сервиса?

Для монолита достаточно structured logging + Prometheus + Grafana. OpenTelemetry становится критичным при микросервисах, когда запрос проходит через несколько сервисов. Начните с логов и метрик, добавьте OTel при росте.

Сколько стоит observability?

Open-source стек (Jaeger + Prometheus + Grafana + Loki) — бесплатно, но нужны ресурсы на хостинг. SaaS (Datadog, Grafana Cloud) — от $0 (free tier) до $$$. Основная стоимость — объём данных: sampling на 10-20% критичен для production.

Как связать observability с дебагом API-ошибок?

Observability — превентивный инструмент. Когда приходит алерт о росте ошибок, вы: 1) Смотрите метрики (какой эндпоинт). 2) Открываете трейс ошибочного запроса. 3) Находите span с ошибкой. 4) Переходите к логам этого span по trace_id. Весь процесс — 2-5 минут вместо часов.

Полезные ссылки

📊 Мониторьте свои API

LightBox API — создавайте и тестируйте API с полным логированием запросов. Видьте каждый запрос: метод, заголовки, тело, время ответа.

Попробовать бесплатно →

Статья опубликована: 23 февраля 2026
Автор: LightBox API Team