← назад к разделу

Когда запрос проходит через несколько сервисов и что-то идёт медленно или с ошибкой, логи и метрики отвечают лишь частично: «была ошибка в payment-service» и «p95 вырос». Но где именно потерялось время и в каком сервисе цепочка сломалась — понять сложно.

Distributed tracing решает эту задачу: он записывает конкретный запрос от входа в систему до выхода, шаг за шагом. В этой статье разберём, как подключить трассировку к FastAPI-сервису через OpenTelemetry.

Что такое span и trace

Представьте запрос POST /orders как дерево вызовов. Каждый узел дерева — это span: именованный отрезок времени с началом, концом и атрибутами. Все spanы одного запроса объединены в trace по общему trace_id.

POST /orders  (100ms)
├── SELECT * FROM orders  (5ms)
├── POST https://payment-service/charge  (80ms)
│   └── SELECT * FROM payments  (3ms)
└── INSERT INTO order_events  (2ms)

В такой картине сразу видно: 80% времени ушло во внешний HTTP-вызов.

OpenTelemetry — это библиотека и стандарт, которые отвечают за создание spanов, передачу trace_id между сервисами и отправку данных в хранилище (Grafana Tempo, Jaeger). Для Python он заменил устаревшие клиенты Zipkin и Brave.

Установка и настройка

Нужно несколько пакетов: сам SDK, инструментации для FastAPI, SQLAlchemy и HTTP-клиента, и экспортёр в коллектор:

opentelemetry-sdk
opentelemetry-instrumentation-fastapi
opentelemetry-instrumentation-sqlalchemy
opentelemetry-instrumentation-httpx
opentelemetry-exporter-otlp-proto-grpc

Затем создаём функцию инициализации:

# app/telemetry.py
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace.sampling import ParentBasedTraceIdRatio
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor


def configure_tracing(app, engine, *, sample_rate: float = 0.1) -> None:
    provider = TracerProvider(sampler=ParentBasedTraceIdRatio(sample_rate))
    provider.add_span_processor(
        BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4317"))
    )
    trace.set_tracer_provider(provider)

    FastAPIInstrumentor.instrument_app(app)
    SQLAlchemyInstrumentor().instrument(engine=engine)
    HTTPXClientInstrumentor().instrument()
# app/main.py
app = FastAPI()
configure_tracing(app, engine, sample_rate=0.1)

После этого, без единой строки в бизнес-коде, уже появляются spanы для каждого входящего HTTP-запроса, SQL-запроса и исходящего HTTP-вызова.

Как traceparent связывает сервисы

Когда запрос приходит во второй сервис, OTel читает заголовок traceparent из входящего HTTP-запроса и продолжает тот же trace, создавая дочерний span. Когда сервис сам делает HTTP-вызов, OTel автоматически добавляет этот заголовок в исходящий запрос:

traceparent: 00-5e92c8a3b1f4d2e6a7c8e9f0a1b2c3d4-1f2e3d4c5b6a7980-01
              │     │                                  │                 │
              │     trace-id (общий для всех сервисов)  span-id          флаги
              версия

Это формат W3C Trace Context. HTTPXClientInstrumentor проставляет его автоматически — явного кода не требуется.

Ручной span для важной бизнес-операции

Автоинструментация покрывает HTTP и SQL, но иногда нужно обозначить конкретную бизнес-операцию как отдельный span. Для этого используют context manager start_as_current_span:

# app/orders/confirm_order.py
import structlog
from opentelemetry import trace
from opentelemetry.trace import StatusCode

log = structlog.get_logger(__name__)
tracer = trace.get_tracer(__name__)


class ConfirmOrderHandler:
    def __init__(self, order_repo: OrderRepository) -> None:
        self._order_repo = order_repo

    async def handle(self, command: ConfirmOrderCommand) -> Order:
        with tracer.start_as_current_span("confirm_order") as span:
            span.set_attribute("order.id", str(command.order_id))
            span.set_attribute("customer.id", str(command.customer_id))

            order = await self._order_repo.find_for_update(command.order_id)
            if order is None:
                span.set_status(StatusCode.ERROR, "order_not_found")
                raise OrderNotFoundError(command.order_id)

            order.confirm()
            span.set_attribute("order.status", order.status.value)

            await self._order_repo.save(order)
            log.info("order_confirmed", order_id=str(command.order_id))
            return order

with tracer.start_as_current_span(...) гарантирует, что span закроется при выходе из блока — даже при исключении. При ошибке OTel автоматически вызывает span.record_exception(exc) и помечает span как ERROR.

Что класть в атрибуты span

Атрибуты span — это дополнительный контекст, который помогает найти нужный trace среди тысяч. Добавляйте бизнес-идентификаторы: order.id, customer.id, значения перечислений (order.status, payment.method).

Важно: трассировочные данные хранятся в Tempo или Jaeger с другим режимом доступа и хранения, чем операционные базы данных. Личные данные пользователей — email, телефон, номер карты — туда класть нельзя.

with tracer.start_as_current_span("publish_product") as span:
    span.set_attribute("product.id", str(command.product_id))
    span.set_attribute("product.category", command.category.value)
    span.set_attribute("seller.id", str(command.seller_id))
    # Неправильно:
    # span.set_attribute("product.description", command.description)
    # span.set_attribute("seller.email", seller.email)

Sampling: сколько трейсов сохранять

Записывать 100% трейсов в нагруженном сервисе — дорого. Обычно используют head-based sampling: записывать случайные 10% запросов.

ParentBasedTraceIdRatio работает так: если входящий traceparent уже помечен как «записывать», дочерние spanы тоже записываются. Иначе — случайные 10%.

provider = TracerProvider(
    sampler=ParentBasedTraceIdRatio(rate=0.1)  # 10%
)

Через переменные окружения (без изменения кода):

OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317

На стороне коллектора (Grafana Alloy, OTel Collector) можно настроить tail-based sampling: записывать 100% трейсов, в которых была ошибка, и 10% всех остальных. Так ни один сбой не пропадёт.

Если сервис получает меньше 10 запросов в секунду — sampling можно не настраивать, хранилище не перегрузится.

trace_id в логах

Когда в Grafana видишь ошибку в логе, хочется одним кликом открыть полный trace для этого запроса. Для этого нужно, чтобы в каждой записи лога был trace_id.

OTel-structlog processor делает это автоматически — добавляет trace_id и span_id в каждую запись, пока идёт обработка запроса:

# app/logging.py
import structlog
from opentelemetry import trace as otel_trace


def add_otel_context(logger, method, event_dict):
    span = otel_trace.get_current_span()
    ctx = span.get_span_context()
    if ctx.is_valid:
        event_dict["trace_id"] = format(ctx.trace_id, "032x")
        event_dict["span_id"] = format(ctx.span_id, "016x")
    return event_dict


structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,
        add_otel_context,
        structlog.processors.JSONRenderer(),
    ]
)

Результат в логе:

{
  "timestamp": "2026-06-19T10:15:30Z",
  "level": "error",
  "event": "payment_failed",
  "order_id": "ord-9912",
  "trace_id": "5e92c8a3b1f4d2e6a7c8e9f0a1b2c3d4",
  "span_id": "1f2e3d4c5b6a7980"
}

В Grafana кликаем на trace_id → Tempo → видим весь distributed trace по шагам.

Контекст в asyncio и thread pool

В asyncio OTel-контекст переносится через await автоматически — не нужно ничего делать:

async def process_order(order_id: UUID) -> None:
    with tracer.start_as_current_span("process_order") as span:
        span.set_attribute("order.id", str(order_id))
        await validate_order(order_id)   # span активен и здесь
        await charge_payment(order_id)   # и здесь

Если часть работы выполняется в отдельном потоке через run_in_executor, контекст нужно скопировать вручную — иначе trace разорвётся:

import asyncio
import contextvars

loop = asyncio.get_event_loop()
ctx = contextvars.copy_context()

result = await loop.run_in_executor(
    None,
    ctx.run,
    sync_heavy_computation,
    order_id,
)

Частые ошибки

Sampling 100% в продакшене — при высокой нагрузке это быстро переполняет хранилище. Используйте ParentBasedTraceIdRatio(0.1) плюс tail-based sampling на коллекторе для ошибок.

Личные данные в атрибутахcustomer.email, card.number не должны попадать в трассировочное хранилище. Только внутренние идентификаторы и значения перечислений.

tracer.start_span(...) без завершения — если не использовать context manager и не вызвать span.end() в блоке finally, span никогда не закроется. Предпочтительный вариант — with tracer.start_as_current_span(...).

run_in_executor без copy_context() — trace разрывается на границе потока: spanы в executor'е не видят родительский span.

Коротко

  • Distributed trace — это дерево spanов для одного запроса. Каждый span — это именованный отрезок времени с атрибутами.
  • traceparent заголовок связывает spanы между сервисами. HTTPXClientInstrumentor проставляет его автоматически.
  • Автоинструментация FastAPI, SQLAlchemy и httpx даёт картину «HTTP → SQL → HTTP-выход» без ручного кода.
  • Ручные spanы создают через with tracer.start_as_current_span(...) — context manager гарантирует закрытие.
  • В атрибуты span — только внутренние идентификаторы и перечисления. Никаких личных данных.
  • Sampling 10% в продакшене + 100% ошибок на стороне коллектора.
  • OTel-structlog processor добавляет trace_id в каждую лог-запись автоматически.
  • В asyncio контекст переходит через await сам; для run_in_executor нужен contextvars.copy_context().

Что почитать дальше

  • Метрики в Python — Prometheus, cardinality, RED-метрики.
  • Логирование в Python — structlog JSON, PII-гигиена.
  • Трассировка в Java — аналогичная статья для Spring Boot.
  • Трассировка в Go — OpenTelemetry с нуля для Go-сервиса.