Observability в FastAPI: structured logs, метрики, tracing
Логирование и observability в Python-сервисе — это история, к которой большинство приходит постфактум. Сначала пишешь print, потом logging.info, потом ставишь Sentry, потом упирается во что-то в проде, и ты осознаёшь, что у тебя нет ни корреляции запросов, ни структурированных полей, ни цельной картины. И начинается чистка.
Расскажу про то, как у нас построено наблюдение в FastAPI-сервисах. Не про «выберите лучший APM», а про конкретный набор практик, который реально работает: structured logs, request_id, OpenTelemetry, метрики Prometheus.
Структурированные логи как базовая привычка
Первый шаг — отучить логи от строкового формата. Запись типа "User 123 created order 456" в продакшене бесполезна: ты не отфильтруешь её по user_id, не сгруппируешь, не построишь графики. Структурированный лог — это JSON-объект с полями.
В Python для этого хорошо подходит structlog. Базовая настройка:
import logging
import structlog
logging.basicConfig(level=logging.INFO, format="%(message)s")
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
cache_logger_on_first_use=True,
)
log = structlog.get_logger()
Теперь все логи в проде — это JSON. Используешь так:
log.info("order.created", user_id=user.id, order_id=order.id, amount=order.total)
В Kibana/Loki можно фильтровать по конкретным полям, считать метрики, строить дашборды. Никакого парсинга строк регулярками.
Request ID и контекст запроса
Один запрос пользователя обычно порождает 5–15 записей лога: на эндпоинте, в сервисе, в репозитории, во внешнем клиенте. Без идентификатора запроса найти их связано — мучение.
Ставим middleware, который генерирует/принимает X-Request-ID и кладёт его в context-vars:
import uuid
import structlog
from fastapi import Request
@app.middleware("http")
async def request_context(request: Request, call_next):
request_id = request.headers.get("x-request-id") or uuid.uuid4().hex
structlog.contextvars.bind_contextvars(request_id=request_id)
try:
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
finally:
structlog.contextvars.clear_contextvars()
Теперь любая запись через structlog в рамках запроса автоматически получит request_id. Прицепить туда же user_id, как только знаем пользователя:
async def get_current_user(...):
user = ...
structlog.contextvars.bind_contextvars(user_id=user.id)
return user
В логах сразу видно, кто пытался что-то сделать, и можно построить трассировку конкретного пользовательского пути.
Access log: один источник правды
uvicorn по умолчанию пишет свой access log в текстовом виде. В проде он скорее мешает, чем помогает: формат свой, структуры нет.
Лучше выключить uvicorn-овский access и написать свой через middleware:
import time
@app.middleware("http")
async def access_log(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
duration = (time.perf_counter() - start) * 1000
log.info(
"request.handled",
method=request.method,
path=request.url.path,
status=response.status_code,
duration_ms=round(duration, 2),
)
return response
Запускаешь uvicorn с --no-access-log. Все логи теперь структурированные и проходят через единый pipeline.
Метрики Prometheus
Метрики и логи закрывают разные потребности. Логи — про «что произошло конкретно с этим запросом». Метрики — про «как ведёт себя система в целом».
Для FastAPI хороший пакет — prometheus-fastapi-instrumentator:
from prometheus_fastapi_instrumentator import Instrumentator
Instrumentator().instrument(app).expose(app, endpoint="/metrics")
Из коробки получаешь:
http_requests_total— количество запросов по методу, пути, статусу.http_request_duration_seconds— гистограмма времени ответа.- Метрики Python-runtime (если включить).
Дальше добавляешь свои бизнес-метрики:
from prometheus_client import Counter, Histogram
orders_created = Counter("orders_created_total", "Created orders", ["country"])
payment_duration = Histogram("payment_duration_seconds", "Payment latency")
async def create_order(...):
orders_created.labels(country=user.country).inc()
with payment_duration.time():
await payment_client.charge(...)
Главное правило: считай то, что важно для бизнеса и для дебага. Не плоди метрики «на всякий случай» — каждая лишняя метрика стоит памяти и в Prometheus, и в приложении.
Tracing через OpenTelemetry
Когда сервисов больше двух и они ходят друг к другу, без трасс становится плохо. OpenTelemetry — стандарт, который всё больше замещает специфичные APM-агенты.
Базовая инструментация FastAPI:
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
resource = Resource(attributes={"service.name": "orders-api"})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint="otel-collector:4317")))
trace.set_tracer_provider(provider)
FastAPIInstrumentor.instrument_app(app)
Дополняешь автоматическую инструментацию SQLAlchemy, httpx, Redis — для каждой есть свой пакет opentelemetry-instrumentation-*. После этого в Jaeger или Tempo видишь полное дерево вызовов: запрос → эндпоинт → сервис → SQL-запросы → внешние HTTP-запросы.
Внутри кода добавляешь свои спаны там, где это имеет смысл:
tracer = trace.get_tracer(__name__)
async def calculate_total(order: Order):
with tracer.start_as_current_span("calculate_total") as span:
span.set_attribute("order.id", order.id)
...
Корреляция логов и трасс
Когда есть trace_id, его удобно прокидывать в логи: тогда из конкретной строки лога можно одним кликом перейти в трассу того же запроса.
from opentelemetry import trace as ot_trace
def add_trace_context(logger, method_name, event_dict):
span = ot_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
# добавляешь add_trace_context в processors structlog
Теперь в каждой записи лога есть trace_id, и в Grafana можно настроить переход «лог → трасса».
Sentry для исключений
Логи с трассами хороши, но не заменяют отдельный канал для ошибок. Sentry (или его аналог) даёт удобную группировку, контекст, deduplication.
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration
sentry_sdk.init(
dsn="https://...",
environment="production",
traces_sample_rate=0.05,
integrations=[FastApiIntegration(), StarletteIntegration()],
)
В прод не лей все ошибки — фильтруй по уровням и не отправляй ожидаемые исключения (валидация, ожидаемые HTTPException). Sentry — про неожиданное.
Чек-лист «у нас normal observability»
- Все логи в JSON, идут в централизованное хранилище.
- В каждой записи есть
request_idи (если применимо)user_id. - Эндпоинты отдают
X-Request-IDв ответе. - Есть
/metricsс базовыми и бизнес-метриками. - Tracing включён, спаны генерируются автоматически для FastAPI/БД/HTTP.
- В каждой записи лога есть
trace_id. - Sentry ловит неожиданные исключения.
- Все 4xx и 5xx видны на дашборде в реальном времени.
Когда хоть один пункт не выполнен — на инциденте чувствуешь, как этого не хватает. Особенно request_id и trace_id. Поднять observability на уровень «всё видно за секунды» стоит пары дней работы, и эта инвестиция окупается на каждой ночной багалии.
Не пытайся сразу включить всё. Иди по списку: structured logs → request_id → метрики → tracing → корреляция → Sentry. Каждый шаг даёт результат, который можно проверить и почувствовать.