Distributed Tracing с OpenTelemetry

Distributed Tracing с OpenTelemetry

Когда запрос идёт через 5 микросервисов - как найти, где тормозит? Логи не связаны между собой, метрики агрегированы. Distributed tracing решает эту проблему: каждый запрос имеет уникальный trace ID, и каждая операция внутри - это span с измеренной длительностью.

Trace через 4 сервиса с waterfall-диаграммой spans и обозначением узкого места в БД

Концепции

  • 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 - для HTTP
  • messaging.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.

Мини-практика

Добавь OpenTelemetry трейсинг к HTTP-серверу: server span на входе, client span на исходящие запросы, отдельный span для каждого SQL-запроса. Запусти Jaeger через Docker, прогони curl через несколько endpoint-ов и посмотри waterfall в UI. Попробуй разные sampling rates: 100%, 10%, 1%.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.