
Каждый раз, когда API «подвис», а клиенты жалуются, команда тратит часы на поиск иголки в стоге логов: где именно тормозит — в базе, в стороннем API или в очередях? Распределённая трассировка даёт прямой ответ: показывает путь запроса через сервисы, время на каждом участке и конкретное место сбоя. В итоге:
OpenTelemetry — это открытый стандарт и набор библиотек для сбора технических сигналов: трассы (цепочки вызовов), метрики и логи. Важные термины:
OpenTelemetry не навязывает конкретное хранилище. Сегодня вы можете отправлять данные в Jaeger, Tempo, Zipkin, в коммерческие APM — меняется только экспортёр/коллектор.
Так вы поднимете визуализацию и разумное семплирование без изменения кода приложений.
# docker-compose.yaml
version: "3.8"
services:
jaeger:
image: jaegertracing/all-in-one:1.54
environment:
- COLLECTOR_OTLP_ENABLED=true
ports:
- "16686:16686" # Веб‑интерфейс Jaeger
- "4317:4317" # OTLP gRPC (если нужно)
- "4318:4318" # OTLP HTTP
otel-collector:
image: otel/opentelemetry-collector:0.98.0
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro
ports:
- "4319:4318" # OTLP HTTP вход для приложений
- "4320:4317" # OTLP gRPC вход (опционально)
depends_on:
- jaeger
Конфиг коллектора: tail‑семплирование (сохраняем все ошибки и медленные трейсы, остальные — выборочно), отправляем в Jaeger через OTLP.
# otel-collector-config.yaml
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
grpc:
endpoint: 0.0.0.0:4317
processors:
batch: {}
tail_sampling:
decision_wait: 10s
num_traces: 10000
expected_new_traces_per_sec: 500
policies:
- name: errors
type: status_code
status_code:
status_codes: [ERROR]
- name: slow_traces
type: latency
latency:
threshold_ms: 300
- name: important_orders
type: string_attribute
string_attribute:
key: business.important
values: ["true"]
- name: probabilistic_rest
type: probabilistic
probabilistic:
sampling_percentage: 10
exporters:
otlphttp/jaeger:
endpoint: http://jaeger:4318
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling, batch]
exporters: [otlphttp/jaeger]
Дальше приложения шлют данные на OTLP HTTP http://localhost:4319 — коллектор решает, что хранить, и отрисовывает в Jaeger (http://localhost:16686).
Покажу два небольших сервиса, чтобы видно было межсервисные вызовы и передачу контекста.
Установим зависимости.
python -m venv .venv && source .venv/bin/activate
pip install fastapi uvicorn httpx
pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp
pip install opentelemetry-instrumentation-fastapi opentelemetry-instrumentation-httpx opentelemetry-instrumentation-logging
# app_checkout.py
import os
import random
import time
import logging
from typing import Optional
import httpx
from fastapi import FastAPI, HTTPException
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.logging import LoggingInstrumentor
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
# Настройка трейсера
OTLP_ENDPOINT = os.getenv("OTLP_HTTP_ENDPOINT", "http://localhost:4319/v1/traces")
resource = Resource.create({"service.name": "checkout", "service.version": "1.0.0"})
provider = TracerProvider(resource=resource, sampler=ParentBased(TraceIdRatioBased(0.3)))
processor = BatchSpanProcessor(OTLPSpanExporter(endpoint=OTLP_ENDPOINT))
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(__name__)
# Логирование с корелляцией по trace_id/span_id
LoggingInstrumentor().instrument(set_logging_format=True)
logger = logging.getLogger("checkout")
logger.setLevel(logging.INFO)
app = FastAPI(title="checkout")
FastAPIInstrumentor.instrument_app(app)
HTTPXClientInstrumentor().instrument()
BILLING_URL = os.getenv("BILLING_URL", "http://localhost:8001/pay")
@app.get("/healthz")
def healthz():
return {"status": "ok"}
@app.post("/checkout")
async def checkout(order_id: str, user_id: str, amount: float, important: Optional[bool] = False):
# Кладём бизнес‑атрибуты в текущий спан
current_span = trace.get_current_span()
current_span.set_attribute("order.id", order_id)
current_span.set_attribute("user.id", user_id)
current_span.set_attribute("amount", amount)
current_span.set_attribute("business.important", str(important).lower())
# Симулируем работу в приложении
with tracer.start_as_current_span("validate_order") as span:
span.set_attribute("rules.count", 3)
time.sleep(random.uniform(0.01, 0.05))
# Вызов внешнего сервиса оплаты (контекст передастся через заголовки автоматически)
async with httpx.AsyncClient(timeout=3.0) as client:
try:
resp = await client.post(BILLING_URL, json={
"order_id": order_id,
"user_id": user_id,
"amount": amount,
"important": important
})
resp.raise_for_status()
data = resp.json()
except httpx.HTTPError as e:
logger.exception("billing call failed")
# Помечаем текущий спан как ошибку
current_span.record_exception(e)
current_span.set_status(trace.Status(trace.StatusCode.ERROR, str(e)))
raise HTTPException(status_code=502, detail="billing unavailable")
return {"order_id": order_id, "payment": data}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
# app_billing.py
import os
import random
import time
import logging
from fastapi import FastAPI, HTTPException
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.logging import LoggingInstrumentor
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
OTLP_ENDPOINT = os.getenv("OTLP_HTTP_ENDPOINT", "http://localhost:4319/v1/traces")
resource = Resource.create({"service.name": "billing", "service.version": "1.0.0"})
provider = TracerProvider(resource=resource, sampler=ParentBased(TraceIdRatioBased(1.0)))
processor = BatchSpanProcessor(OTLPSpanExporter(endpoint=OTLP_ENDPOINT))
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(__name__)
LoggingInstrumentor().instrument(set_logging_format=True)
logger = logging.getLogger("billing")
logger.setLevel(logging.INFO)
app = FastAPI(title="billing")
FastAPIInstrumentor.instrument_app(app)
@app.get("/healthz")
def healthz():
return {"status": "ok"}
@app.post("/pay")
def pay(payload: dict):
order_id = payload.get("order_id")
amount = float(payload.get("amount", 0))
important = bool(payload.get("important", False))
span = trace.get_current_span()
span.set_attribute("order.id", order_id)
span.set_attribute("amount", amount)
# Имитация внешнего провайдера с задержками и ошибками
with tracer.start_as_current_span("acquirer") as s:
delay = random.uniform(0.02, 0.6 if important else 0.25)
time.sleep(delay)
s.set_attribute("simulated.delay_ms", int(delay * 1000))
# 5% ошибок обычным, 1% — важным заказам
failure_rate = 0.05 if not important else 0.01
if random.random() < failure_rate:
err = RuntimeError("acquirer timeout")
s.record_exception(err)
s.set_status(trace.Status(trace.StatusCode.ERROR, str(err)))
logger.error("payment failed for order %s", order_id)
raise HTTPException(status_code=504, detail="acquirer timeout")
logger.info("payment ok for order %s", order_id)
return {"status": "ok", "order_id": order_id, "amount": amount}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)
Запуск:
# 1) Поднимаем Jaeger и Collector
docker compose up -d
# 2) Запускаем сервисы (в разных терминалах)
export OTLP_HTTP_ENDPOINT=http://localhost:4319/v1/traces
python app_billing.py
python app_checkout.py
# 3) Делаем несколько запросов (часть будет медленной/с ошибками)
curl -X POST "http://localhost:8000/checkout?order_id=42&user_id=7&amount=199.9&important=true"
curl -X POST "http://localhost:8000/checkout?order_id=43&user_id=1&amount=49.9"
# 4) Смотрим трейсы
open http://localhost:16686
В Jaeger увидите единый трейс: входящий запрос в checkout → внутренние шаги → HTTP‑вызов в billing → спан «acquirer». Ошибки и медленные трейсы гарантированно сохраняются коллектором.
Практический совет: дублируйте в атрибутах спанов бизнес‑ключи — например, order.id, partner.id, план тарифа. Это позволит быстро фильтровать проблемные потоки и спорить с партнёрами на цифрах.
Трейсы «весят» заметно больше метрик, и писать каждый — дорого. Баланс простой: сохраняем всё проблемное, остальное — выборочно.
Мы настроили ParentBased + TraceIdRatioBased(0.3) в checkout — это означает, что корневой запрос попадёт в хранилище с вероятностью 30%, и все его дочерние спаны тоже сохранятся. Плюс, если downstream‑сервис (billing) получил контекст, он уважит решение родителя и не «потеряет» трейс.
Плюсы: просто и дёшево, не нужен отдельный софт. Минусы: можно промахнуться мимо редкой ошибки или медленного случая — решение о сохранении принимается в момент начала трейса.
Коллектор ждёт завершения трейса и решает, хранить его или нет, уже зная исход: был ли статус ERROR, превысил ли задержку порог. Конфиг выше делает именно это:
Итог: затраты под контролем, качество данных для расследований высокое.
Простой расчёт: если у вас 3 инцидента в месяц на 2 часа каждый с вовлечением 3 инженеров по 2 500 ₽/час — это ~45 000 ₽/мес прямых затрат. Снижение вдвое окупает внедрение за 1–2 месяца даже при платном хранилище.
Итог: OpenTelemetry даёт наблюдаемость уровня «рентген» без привязки к одному вендору. Начните с малого — один сервис, одна болевая точка — и уже через неделю инциденты станут короче и прогнозируемее.