Когда запрос проходит через несколько сервисов и что-то идёт медленно или с ошибкой, логи и метрики отвечают лишь частично: «была ошибка в 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-сервиса.