Distributed Tracing с OpenTelemetry
Distributed Tracing с OpenTelemetry
Когда запрос идёт через 5 микросервисов - как найти, где тормозит? Логи не связаны между собой, метрики агрегированы. Distributed tracing решает эту проблему: каждый запрос имеет уникальный trace ID, и каждая операция внутри - это span с измеренной длительностью.
Концепции
- Trace - путь запроса через всю систему. Один запрос = один trace.
- Span - атомарная операция: HTTP-вызов, SQL-запрос, обращение к Redis. У span есть имя, начало, конец, атрибуты, parent.
- Context propagation - передача
trace_idиspan_idмежду сервисами через HTTP-заголовки (traceparent). - Sampling - какую долю traces сохранять. 100% обычно слишком дорого; sampling 1-10% - типичный продакшен.
OpenTelemetry vs OpenTracing vs OpenCensus
OpenTelemetry - слияние OpenTracing и OpenCensus, де-факто стандарт CNCF. Если начинаете сейчас, выбор очевиден: OTel. Раньше были вендорные SDK (Jaeger native, Zipkin), сейчас они в режиме deprecated в пользу OTel.
Настройка SDK
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)
func initTracer(ctx context.Context) (*trace.TracerProvider, error) {
exporter, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint("localhost:4318"),
otlptracehttp.WithInsecure(),
)
if err != nil {
return nil, err
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName("user-api"),
semconv.ServiceVersion("1.2.3"),
semconv.DeploymentEnvironment("production"),
),
)
if err != nil {
return nil, err
}
tp := trace.NewTracerProvider(
trace.WithBatcher(exporter),
trace.WithResource(res),
trace.WithSampler(trace.TraceIDRatioBased(0.1)), // 10%
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.TraceContext{})
return tp, nil
}
<?php
declare(strict_types=1);
use OpenTelemetry\API\Globals;
use OpenTelemetry\SDK\Resource\ResourceInfo;
use OpenTelemetry\SDK\Resource\ResourceInfoFactory;
use OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor;
use OpenTelemetry\SDK\Trace\Sampler\TraceIdRatioBasedSampler;
use OpenTelemetry\SDK\Trace\Sampler\ParentBased;
use OpenTelemetry\SDK\Trace\TracerProvider;
use OpenTelemetry\Contrib\Otlp\SpanExporter;
use OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory;
use OpenTelemetry\SemConv\ResourceAttributes;
$transport = (new OtlpHttpTransportFactory())->create('http://localhost:4318/v1/traces', 'application/x-protobuf');
$exporter = new SpanExporter($transport);
$resource = ResourceInfoFactory::defaultResource()->merge(
ResourceInfo::create(\OpenTelemetry\SDK\Common\Attribute\Attributes::create([
ResourceAttributes::SERVICE_NAME => 'user-api',
ResourceAttributes::SERVICE_VERSION => '1.2.3',
ResourceAttributes::DEPLOYMENT_ENVIRONMENT => 'production',
])),
);
$tracerProvider = TracerProvider::builder()
->addSpanProcessor(new BatchSpanProcessor($exporter, \OpenTelemetry\API\Common\Time\Clock::getDefault()))
->setResource($resource)
->setSampler(new ParentBased(new TraceIdRatioBasedSampler(0.1))) // 10%
->build();
Globals::registerInitializer(static fn ($builder) => $builder->withTracerProvider($tracerProvider));
В PHP индустриальный пакет - open-telemetry/sdk. Bootstrap делается один раз при старте приложения (в Symfony - через bundle open-telemetry/opentelemetry-auto-symfony либо вручную в config/services.php).
Resource описывает сервис в целом (имя, версия, окружение). Эти атрибуты автоматически попадают в каждый span - их не нужно указывать вручную.
Создание spans
tracer := otel.Tracer("user-api")
func handleRequest(ctx context.Context) error {
ctx, span := tracer.Start(ctx, "handleRequest")
defer span.End()
span.SetAttributes(
attribute.String("user.id", userID),
attribute.Int("query.limit", 100),
)
// Дочерний span для БД
if err := queryDB(ctx); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
return nil
}
func queryDB(ctx context.Context) error {
ctx, span := tracer.Start(ctx, "db.query",
trace.WithSpanKind(trace.SpanKindClient),
)
defer span.End()
span.SetAttributes(
attribute.String("db.system", "postgresql"),
attribute.String("db.statement", "SELECT id FROM users WHERE ..."),
)
// ... выполнение запроса
return nil
}
<?php
declare(strict_types=1);
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\StatusCode;
use OpenTelemetry\SemConv\TraceAttributes;
final class UserHandler
{
public function handle(string $userId): void
{
$tracer = Globals::tracerProvider()->getTracer('user-api');
$span = $tracer->spanBuilder('handleRequest')->startSpan();
$scope = $span->activate();
try {
$span->setAttribute('user.id', $userId);
$span->setAttribute('query.limit', 100);
$this->queryDb($tracer);
} catch (\Throwable $e) {
$span->recordException($e);
$span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
throw $e;
} finally {
$scope->detach();
$span->end();
}
}
private function queryDb($tracer): void
{
$span = $tracer->spanBuilder('db.query')
->setSpanKind(SpanKind::KIND_CLIENT)
->startSpan();
$scope = $span->activate();
try {
$span->setAttribute(TraceAttributes::DB_SYSTEM, 'postgresql');
$span->setAttribute(TraceAttributes::DB_STATEMENT, 'SELECT id FROM users WHERE ...');
// ... выполнение запроса
} finally {
$scope->detach();
$span->end();
}
}
}
В PHP нет context.Context - активный span хранится в глобальном Context::getCurrent(). Чтобы открыть child span, используется паттерн activate() с Scope: пока scope живёт, span - активный.
tracer.Start в Go возвращает новый ctx, в котором уже есть span. В PHP роль ctx играет implicit стек активных span - $scope->detach() снимает текущий с вершины. Главная разница: в PHP пропуск detach() оставит «висящий» span - всегда используй try/finally.
Context propagation между сервисами
Когда сервис A зовёт сервис B по HTTP, trace должен продолжиться. OpenTelemetry инжектит/извлекает заголовок traceparent:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
^^ ^trace_id^ ^span_id^ ^^
version flags
// На стороне клиента (исходящий запрос)
client := &http.Client{
Transport: otelhttp.NewTransport(http.DefaultTransport),
}
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, _ := client.Do(req) // traceparent автоматически добавится
// На стороне сервера (входящий запрос)
http.Handle("/users", otelhttp.NewHandler(
http.HandlerFunc(usersHandler),
"users-endpoint",
))
<?php
declare(strict_types=1);
use OpenTelemetry\API\Globals;
use OpenTelemetry\Context\Context;
use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter;
// Сервер: извлечь parent-context из входящих заголовков
final class IncomingTraceExtractor
{
public function extract(array $headers): Context
{
$propagator = Globals::propagator();
return $propagator->extract($headers, ArrayAccessGetterSetter::getInstance());
}
}
// Клиент: вшить traceparent в исходящий запрос (Symfony HttpClient)
final class TracingHttpClient
{
public function __construct(
private readonly \Symfony\Contracts\HttpClient\HttpClientInterface $inner,
) {}
public function get(string $url): \Symfony\Contracts\HttpClient\ResponseInterface
{
$carrier = [];
Globals::propagator()->inject($carrier, ArrayAccessGetterSetter::getInstance());
return $this->inner->request('GET', $url, ['headers' => $carrier]);
}
}
В Symfony с open-telemetry/opentelemetry-auto-symfony инструментирование HTTP-входов и HttpClient-исходящих делается автоматически. Если нужно вручную - используются propagators OTel SDK.
OTel propagator автоматически использует W3C-формат traceparent - тот же самый, что и Go otelhttp. Сервисы на разных языках связываются прозрачно.
Jaeger в Docker
services:
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # UI
- "4318:4318" # OTLP HTTP
- "4317:4317" # OTLP gRPC
environment:
- COLLECTOR_OTLP_ENABLED=true
В UI Jaeger (http://localhost:16686) можно искать traces по сервису, имени операции, тегам. Видна полная waterfall-диаграмма: где время потрачено, какие spans были в parallel, где case retry.
Sampling strategies
100% sampling в проде - дорого. Варианты:
- Head-based (TraceIDRatioBased): решение принимается на старте trace. Простое, дешёвое, теряем детали при низкой ставке.
- Tail-based: коллектор собирает все spans, потом решает - сохранить или отбросить. Можно сохранять только медленные/ошибочные. Дороже по ресурсам.
- AlwaysSample для критичных сервисов с маленьким RPS, NeverSample для healthcheck-эндпоинтов.
// Условный sampler: всегда сохранять ошибки
type errorSampler struct{}
func (e errorSampler) ShouldSample(p trace.SamplingParameters) trace.SamplingResult {
// На старте мы не знаем про ошибку - это работает только для tail-based
return trace.SamplingResult{Decision: trace.RecordAndSample}
}
<?php
declare(strict_types=1);
use OpenTelemetry\Context\ContextInterface;
use OpenTelemetry\SDK\Trace\SamplerInterface;
use OpenTelemetry\SDK\Trace\SamplingResult;
final class AlwaysRecordSampler implements SamplerInterface
{
public function shouldSample(
ContextInterface $parentContext,
string $traceId,
string $spanName,
int $spanKind,
\OpenTelemetry\SDK\Common\Attribute\AttributesInterface $attributes,
array $links,
): SamplingResult {
// На старте ничего не знаем про итоговый статус: решение по ошибкам
// делается в tail-based sampling на OpenTelemetry Collector
return new SamplingResult(SamplingResult::RECORD_AND_SAMPLE);
}
public function getDescription(): string
{
return 'AlwaysRecordSampler';
}
}
В PHP SDK кастомный sampler - класс, реализующий SamplerInterface.
Span kinds и semantic conventions
OpenTelemetry стандартизирует имена атрибутов. Это позволяет UI и анализаторам понимать данные без настройки:
db.system,db.statement,db.operation- для БДhttp.method,http.url,http.status_code- для HTTPmessaging.system,messaging.destination- для очередейrpc.service,rpc.method- для gRPC
Span kinds: Server, Client, Producer, Consumer, Internal. Они помогают понять, кто инициатор операции - нужно для корректного построения waterfall.
OpenTelemetry Collector: критическая прод-компонента
В learn-схеме приложение шлёт spans прямо в Jaeger. В проде так не делают. Между приложением и backend ставят OpenTelemetry Collector - отдельный процесс, который принимает телеметрию, обрабатывает её и переотправляет в одно или несколько хранилищ.
Зачем нужен:
- Развязка: приложение шлёт в localhost-Collector (быстро, надёжно), а Collector уже занимается ретраями, batch-ом, маршрутизацией.
- Уменьшение нагрузки: batch-processor амортизирует сетевые вызовы - вместо 10K мелких отправок одна большая.
- Multi-export: одни и те же traces могут идти в Jaeger (для дебага), Tempo (для долгосрочного хранения) и Datadog (для бизнеса) одновременно.
- Tail-based sampling: решение «сохранять ли trace» можно принять на Collector-е, когда видны все spans, а не на старте.
Pipeline: receivers → processors → exporters:
# otel-collector-config.yaml
receivers:
otlp:
protocols:
http: {endpoint: 0.0.0.0:4318}
grpc: {endpoint: 0.0.0.0:4317}
processors:
batch:
send_batch_size: 1000
timeout: 5s
tail_sampling:
decision_wait: 10s
policies:
- name: errors
type: status_code
status_code: {status_codes: [ERROR]}
- name: slow
type: latency
latency: {threshold_ms: 1000}
- name: random-1pct
type: probabilistic
probabilistic: {sampling_percentage: 1}
exporters:
otlphttp/tempo:
endpoint: http://tempo:4318
otlphttp/jaeger:
endpoint: http://jaeger:4318
service:
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling, batch]
exporters: [otlphttp/tempo, otlphttp/jaeger]
Tail-based sampling - киллер-фича Collector-а: сохраняем 100% ошибочных и медленных traces, плюс 1% случайных. Это даёт всю отладочную ценность при низкой стоимости.
Correlation: logs ↔ traces ↔ metrics
Три сигнала observability работают вместе только если у них общий ключ - trace_id. Тогда становится возможным:
- В Grafana panel «Logs» видна запись с ошибкой → клик на trace_id → открывается trace в Tempo/Jaeger.
- В Grafana panel «Latency p99» виден всплеск → клик на exemplar → открывается тот самый медленный trace.
- В Jaeger открыт trace → ссылка «show logs for this trace» → Loki показывает все логи с этим trace_id.
LOG: [trace_id=abc123] user-api: query timeout
TRACE: trace_id=abc123 → span "db.query" → 5.2s → error
METRIC: histogram_quantile(0.99) → exemplar trace_id=abc123 → 5.2s
Технически: в логах - handler с trace_id/span_id из ctx (см. предыдущий урок про otelslog). В метриках - exemplars (см. предыдущий урок про Prometheus). В трейсах - Resource attributes для фильтрации. Все три сигнала смотрят в одну Grafana или комбайн, где переходы реализованы автоматически.
Baggage: бизнес-атрибуты через всю цепочку
Span attributes остаются на конкретном span. Если хочется передать tenant_id или experiment_id через 5 микросервисов, чтобы все они логировали и трейсили с этим контекстом - нужен Baggage.
import "go.opentelemetry.io/otel/baggage"
// Сервис A: положить tenant_id в baggage
mem, _ := baggage.NewMember("tenant_id", tenantID)
bag, _ := baggage.New(mem)
ctx = baggage.ContextWithBaggage(ctx, bag)
// Делаем HTTP-запрос с otelhttp - baggage уходит в заголовке baggage:
client := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}
req, _ := http.NewRequestWithContext(ctx, "GET", "http://service-b/api", nil)
client.Do(req)
// Сервис B: получить tenant_id
b := baggage.FromContext(ctx)
tenantID := b.Member("tenant_id").Value()
<?php
declare(strict_types=1);
use OpenTelemetry\API\Baggage\Baggage;
use OpenTelemetry\Context\Context;
// Сервис A: положить tenant_id в baggage
$baggage = Baggage::getBuilder()
->set('tenant_id', $tenantId)
->build();
$scope = Context::storage()->attach($baggage->storeInContext(Context::getCurrent()));
try {
// Любой HTTP-запрос через autoinstrumented HttpClient автоматически
// пробросит заголовок baggage: tenant_id=...
$this->httpClient->request('GET', 'http://service-b/api');
} finally {
$scope->detach();
}
// Сервис B: получить tenant_id из активного контекста
$tenantId = Baggage::fromContext(Context::getCurrent())->getValue('tenant_id');
В PHP SDK Baggage хранится в текущем Context. Activate-pattern такой же, как для span: пока scope живёт, baggage активен; на propagate его автоматически подхватит W3C-пропагатор и положит в заголовок baggage:.
Отличие от span attributes:
- Attributes - локальные, только на span; не пробрасываются.
- Baggage - propagation через HTTP-заголовок
baggage:автоматически, доступно во всех downstream-сервисах через ctx.
Use cases: tenant_id для multi-tenancy, experiment_id для A/B, user_role для условной логики в downstream. Не клади в baggage чувствительные данные (он уходит по сети plaintext в HTTP-заголовке) и большие объекты (увеличивает payload).
Span links: async-сценарии
Parent-child работает синхронно: caller дождался callee, parent ещё открыт, когда создаётся child. Async ломает эту модель: producer пишет в Kafka, span закрывается; consumer читает через час и хочет связаться с producer-ом.
// Producer
ctx, producerSpan := tracer.Start(ctx, "kafka.produce")
defer producerSpan.End()
// Сохраняем span context в headers сообщения
headers := propagation.HeaderCarrier{}
otel.GetTextMapPropagator().Inject(ctx, headers)
sendToKafka(topic, message, headers)
// Consumer (минуты или часы позже)
producerCtx := otel.GetTextMapPropagator().Extract(ctx, headers)
producerSpanCtx := trace.SpanContextFromContext(producerCtx)
// Создаём span с link на producer
ctx, consumerSpan := tracer.Start(
context.Background(),
"kafka.consume",
trace.WithLinks(trace.Link{SpanContext: producerSpanCtx}),
)
defer consumerSpan.End()
<?php
declare(strict_types=1);
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\SpanContext;
use OpenTelemetry\API\Trace\Span;
use OpenTelemetry\Context\Context;
use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter;
// Producer (отправка сообщения в очередь)
$tracer = Globals::tracerProvider()->getTracer('kafka-producer');
$producerSpan = $tracer->spanBuilder('kafka.produce')->startSpan();
$scope = $producerSpan->activate();
try {
$headers = [];
Globals::propagator()->inject($headers, ArrayAccessGetterSetter::getInstance());
$this->kafkaProducer->send($topic, $message, $headers);
} finally {
$scope->detach();
$producerSpan->end();
}
// Consumer (минуты/часы позже)
$producerContext = Globals::propagator()->extract($message->headers, ArrayAccessGetterSetter::getInstance());
$producerSpanContext = Span::fromContext($producerContext)->getContext();
$consumerSpan = $tracer->spanBuilder('kafka.consume')
->addLink($producerSpanContext)
->startSpan();
$consumerSpan->end();
В Symfony Messenger sender/receiver - аналогичные роли. Producer вшивает trace-headers в message stamps; consumer достаёт их и линкует child-span к producer-span.
В Jaeger UI consumer-trace покажет «linked from» с возможностью перейти к producer-trace. Это не parent-child (там бы пришлось ждать closure parent-а часами), а семантически близкая связь - «producer заставил меня запуститься».
Resource semantic conventions
Resource описывает сервис целиком: имя, версия, окружение, инстанс. Эти атрибуты автоматически попадают в каждый span - не нужно класть их вручную. Правильное Resource - основа фильтрации и группировки в Jaeger/Tempo.
import semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
res, _ := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName("user-api"),
semconv.ServiceVersion(buildVersion),
semconv.ServiceInstanceID(hostname),
semconv.DeploymentEnvironment(env), // production/staging/dev
semconv.K8SPodName(os.Getenv("POD_NAME")),
semconv.K8SNamespaceName(os.Getenv("POD_NAMESPACE")),
semconv.K8SNodeName(os.Getenv("NODE_NAME")),
),
)
<?php
declare(strict_types=1);
use OpenTelemetry\SDK\Resource\ResourceInfo;
use OpenTelemetry\SDK\Common\Attribute\Attributes;
use OpenTelemetry\SemConv\ResourceAttributes;
$resource = ResourceInfo::create(Attributes::create([
ResourceAttributes::SERVICE_NAME => 'user-api',
ResourceAttributes::SERVICE_VERSION => $buildVersion,
ResourceAttributes::SERVICE_INSTANCE_ID => gethostname() ?: 'unknown',
ResourceAttributes::DEPLOYMENT_ENVIRONMENT => $_SERVER['APP_ENV'] ?? 'dev',
ResourceAttributes::K8S_POD_NAME => $_SERVER['POD_NAME'] ?? '',
ResourceAttributes::K8S_NAMESPACE_NAME => $_SERVER['POD_NAMESPACE'] ?? '',
ResourceAttributes::K8S_NODE_NAME => $_SERVER['NODE_NAME'] ?? '',
]));
В PHP SDK semconv-константы лежат в пакете open-telemetry/sem-conv (класс ResourceAttributes).
Используйте константы из semconv, а не «свои» имена. UI и анализаторы знают эти ключи и автоматически разбирают их (deployment.environment → фильтр окружения, service.version → группировка по релизам). Самописные env, pod, release придётся проставлять руками в каждом дашборде.
Производительность
Tracing накладывает overhead: создание span, аллокация, экспорт. Типичный 1-3% CPU при 10% sampling. Для горячих циклов выключайте трейсинг внутри (или используйте oteltrace.NoopSpan). trace.WithBatcher асинхронно отправляет spans - не блокирует hot path.
- Python - Kafka с aiokafka: tracing в async producer/consumer - как добавить OpenTelemetry spans к Kafka-продюсеру и консьюмеру
Мини-практика
Добавь OpenTelemetry трейсинг к HTTP-серверу: server span на входе, client span на исходящие запросы, отдельный span для каждого SQL-запроса. Запусти Jaeger через Docker, прогони curl через несколько endpoint-ов и посмотри waterfall в UI. Попробуй разные sampling rates: 100%, 10%, 1%.