Hard💻Практика14 min

OpenTelemetry: unified observability

OpenTelemetry SDK, Collector, distributed tracing, метрики и логи с примерами на PHP и Go

OpenTelemetry: unified observability

Зачем нужен OpenTelemetry

До OpenTelemetry каждый vendor предлагал свой SDK для сбора телеметрии. Jaeger — свой клиент для трейсинга, Datadog — свой агент, New Relic — свой SDK. Смена бэкенда означала переписывание инструментации во всех сервисах.

OpenTelemetry (OTel) — это vendor-neutral стандарт и набор инструментов для сбора, обработки и экспорта телеметрии. Он объединяет три сигнала наблюдаемости в единый SDK:

Проблема Без OTel С OTel
Vendor lock-in Переписываем код при смене бэкенда Меняем только exporter в конфиге
Разные SDK Jaeger client + Prometheus client + Logstash Один OTel SDK для всего
Корреляция trace_id в трейсах, но не в логах Автоматическая корреляция traces + metrics + logs
Стандартизация Каждый vendor — свой формат Единые semantic conventions

OpenTelemetry — это проект CNCF (Cloud Native Computing Foundation), второй по активности после Kubernetes.

Три сигнала OTel

┌─────────────────────────────────────────────────────────┐
│                    OpenTelemetry SDK                      │
│                                                          │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐   │
│  │    Traces    │  │   Metrics    │  │     Logs     │   │
│  │              │  │              │  │              │   │
│  │ Distributed  │  │  Counters,   │  │  Structured  │   │
│  │ tracing:     │  │  Histograms, │  │  logs with   │   │
│  │ spans, ctx   │  │  Gauges      │  │  trace_id    │   │
│  │ propagation  │  │              │  │  correlation  │   │
│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘   │
│         │                 │                 │            │
│         └─────────────────┼─────────────────┘            │
│                           │                              │
│                    ┌──────▼───────┐                       │
│                    │   OTLP       │                       │
│                    │  Protocol    │                       │
│                    └──────────────┘                       │
└─────────────────────────────────────────────────────────┘
Сигнал Что описывает Пример использования
Traces Путь запроса через систему (spans) Где тормозит запрос, какой сервис вызвал ошибку
Metrics Числовые агрегаты во времени RPS, error rate, latency percentiles
Logs Дискретные события Детали ошибок, аудит, отладка бизнес-логики

Архитектура OpenTelemetry

┌──────────────────────────────────────────────────────────────────────┐
│                         Application Layer                            │
│                                                                      │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐     │
│  │   PHP Service    │  │   Go Service    │  │   Node Service  │     │
│  │                  │  │                 │  │                 │     │
│  │  OTel PHP SDK    │  │  OTel Go SDK   │  │  OTel JS SDK   │     │
│  │  ┌────────────┐  │  │  ┌───────────┐ │  │  ┌───────────┐ │     │
│  │  │Auto-instr. │  │  │  │ otelhttp  │ │  │  │Auto-instr.│ │     │
│  │  │ + Manual   │  │  │  │ + otelsql │ │  │  │ + Manual  │ │     │
│  │  └─────┬──────┘  │  │  └─────┬─────┘ │  │  └─────┬─────┘ │     │
│  └────────┼─────────┘  └───────┼────────┘  └───────┼────────┘     │
│           │    OTLP/gRPC       │                    │              │
└───────────┼────────────────────┼────────────────────┼──────────────┘
            │                    │                    │
            ▼                    ▼                    ▼
┌──────────────────────────────────────────────────────────────────────┐
│                       OTel Collector                                  │
│                                                                      │
│  ┌──────────────┐   ┌──────────────┐   ┌──────────────────┐         │
│  │  Receivers    │ → │  Processors  │ → │   Exporters      │         │
│  │              │   │              │   │                  │         │
│  │  otlp (gRPC) │   │  batch       │   │  otlp → Tempo   │         │
│  │  otlp (HTTP) │   │  memory_     │   │  prometheus     │         │
│  │  prometheus  │   │    limiter   │   │  loki           │         │
│  │  zipkin      │   │  filter      │   │  otlp → Datadog │         │
│  │  jaeger      │   │  attributes  │   │  otlp → NewRelic│         │
│  └──────────────┘   │  tail_       │   └──────────────────┘         │
│                     │    sampling  │                                 │
│                     └──────────────┘                                 │
└──────────────────────────────────────────────────────────────────────┘
            │                    │                    │
            ▼                    ▼                    ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│  Grafana Tempo   │ │   Prometheus     │ │   Grafana Loki   │
│  (Traces)        │ │   (Metrics)      │ │   (Logs)         │
└────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘
         │                    │                     │
         └────────────────────┼─────────────────────┘
                              ▼
                     ┌──────────────────┐
                     │     Grafana      │
                     │  Unified view:   │
                     │  traces+metrics  │
                     │  +logs           │
                     └──────────────────┘

Три ключевых компонента:

Компонент Роль Где работает
SDK Инструментация приложения (auto + manual) В процессе приложения
Collector Приём, обработка, экспорт телеметрии Отдельный сервис (sidecar или standalone)
Backend Хранение и визуализация Jaeger, Tempo, Prometheus, Grafana

Traces — Distributed Tracing

Ключевые концепции

Концепция Описание
Trace Полный путь запроса через все сервисы (дерево spans)
Span Одна операция: HTTP-запрос, SQL-запрос, вызов функции
SpanContext trace_id + span_id + trace_flags — передаётся между сервисами
Baggage key-value пары, передаваемые по всей цепочке (tenant_id, user_id)
Parent Span Span, породивший текущий (образует дерево)

Context Propagation

Когда Service A вызывает Service B, trace context передаётся через HTTP-заголовки по стандарту W3C Trace Context:

# W3C Trace Context headers
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │   │                                │                │
             │   │ trace_id (128-bit)              │ parent_id      │ flags
             │ version                             │ (64-bit)       │ (sampled)
             │                                     │                │

tracestate: vendor1=value1,vendor2=value2
            # vendor-specific context

Это позволяет трейсу пересекать границы сервисов, языков и даже облачных провайдеров.

Пример трейса

Trace ID: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6

[API Gateway]────────────────────────── 250ms
 ├── [Auth Middleware]─────── 12ms
 │    └── [Redis: get session]── 2ms
 ├── [OrderController]──────────────── 220ms
 │    ├── [Doctrine: SELECT order]──── 15ms
 │    ├── [PaymentService HTTP]─────────────── 180ms
 │    │    ├── [PaymentAPI: validate]──── 45ms
 │    │    ├── [PaymentAPI: charge]────── 120ms
 │    │    └── [Redis: cache token]── 3ms
 │    └── [RabbitMQ: publish event]── 5ms
 └── [Serializer: response]── 3ms

PHP: OpenTelemetry SDK + Symfony

Установка:

composer require open-telemetry/sdk \
    open-telemetry/exporter-otlp \
    open-telemetry/transport-grpc

Конфигурация через переменные окружения:

# .env
OTEL_SERVICE_NAME=order-service
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1  # sample 10% of traces
OTEL_PHP_AUTOLOAD_ENABLED=true

::code-group

<?php

declare(strict_types=1);

namespace App\Middleware;

use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\StatusCode;
use OpenTelemetry\Context\Context;
use OpenTelemetry\SDK\Trace\TracerProviderInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class TracingMiddleware implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::REQUEST => ['onRequest', 255],
            KernelEvents::RESPONSE => ['onResponse', -255],
            KernelEvents::EXCEPTION => ['onException', 0],
        ];
    }

    public function onRequest(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $request = $event->getRequest();
        $tracer = Globals::tracerProvider()->getTracer('symfony-app');

        // Extract parent context from incoming headers (W3C Trace Context)
        $parentContext = Globals::propagator()->extract(
            $request->headers->all(),
        );

        $span = $tracer->spanBuilder($request->getMethod() . ' ' . $request->getPathInfo())
            ->setParent($parentContext)
            ->setSpanKind(SpanKind::KIND_SERVER)
            ->setAttribute('http.method', $request->getMethod())
            ->setAttribute('http.url', $request->getUri())
            ->setAttribute('http.route', $request->attributes->get('_route', 'unknown'))
            ->setAttribute('http.user_agent', $request->headers->get('User-Agent', ''))
            ->startSpan();

        // Store span in request attributes for later use
        $request->attributes->set('_otel_span', $span);
        $request->attributes->set('_otel_scope', $span->activate());
    }

    public function onResponse(ResponseEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $request = $event->getRequest();
        $span = $request->attributes->get('_otel_span');
        $scope = $request->attributes->get('_otel_scope');

        if ($span === null) {
            return;
        }

        $response = $event->getResponse();
        $span->setAttribute('http.status_code', $response->getStatusCode());

        if ($response->getStatusCode() >= 400) {
            $span->setStatus(StatusCode::STATUS_ERROR, 'HTTP ' . $response->getStatusCode());
        }

        $scope->detach();
        $span->end();
    }

    public function onException(ExceptionEvent $event): void
    {
        $span = $event->getRequest()->attributes->get('_otel_span');
        if ($span === null) {
            return;
        }

        $span->recordException($event->getThrowable());
        $span->setStatus(StatusCode::STATUS_ERROR, $event->getThrowable()->getMessage());
    }
}

::

Создание ручных spans для бизнес-логики:

::code-group

<?php

declare(strict_types=1);

namespace App\Service;

use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\StatusCode;

final readonly class OrderService
{
    public function __construct(
        private PaymentGateway $paymentGateway,
        private OrderRepository $orderRepository,
    ) {}

    public function processOrder(int $orderId): OrderResult
    {
        $tracer = Globals::tracerProvider()->getTracer('order-service');

        // Create a span for the entire order processing
        $span = $tracer->spanBuilder('OrderService.processOrder')
            ->setSpanKind(SpanKind::KIND_INTERNAL)
            ->setAttribute('order.id', $orderId)
            ->startSpan();

        $scope = $span->activate();

        try {
            // Child span: load order from DB
            $loadSpan = $tracer->spanBuilder('DB: load order')
                ->setAttribute('db.system', 'postgresql')
                ->setAttribute('db.operation', 'SELECT')
                ->startSpan();

            $order = $this->orderRepository->find($orderId);
            $loadSpan->setAttribute('order.total', $order->getTotal());
            $loadSpan->end();

            if ($order === null) {
                throw new \RuntimeException("Order {$orderId} not found");
            }

            // Child span: process payment (external API call)
            $paymentSpan = $tracer->spanBuilder('Payment: charge')
                ->setSpanKind(SpanKind::KIND_CLIENT)
                ->setAttribute('payment.amount', $order->getTotal())
                ->setAttribute('payment.currency', 'USD')
                ->startSpan();

            $paymentResult = $this->paymentGateway->charge($order);
            $paymentSpan->setAttribute('payment.transaction_id', $paymentResult->transactionId);
            $paymentSpan->end();

            $span->setAttribute('order.status', 'completed');

            return new OrderResult(success: true, transactionId: $paymentResult->transactionId);
        } catch (\Throwable $e) {
            $span->recordException($e);
            $span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());

            return new OrderResult(success: false, error: $e->getMessage());
        } finally {
            $scope->detach();
            $span->end();
        }
    }
}

::

Go: OpenTelemetry SDK

Установка:

go get go.opentelemetry.io/otel \
    go.opentelemetry.io/otel/sdk \
    go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc \
    go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp \
    go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc

Инициализация TracerProvider:

::code-group

package telemetry

import (
	"context"
	"time"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
	"go.opentelemetry.io/otel/propagation"
	"go.opentelemetry.io/otel/sdk/resource"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)

// Init sets up the OpenTelemetry TracerProvider and returns a shutdown function.
func Init(ctx context.Context, serviceName, collectorEndpoint string) (func(context.Context) error, error) {
	// Create OTLP exporter (sends to OTel Collector via gRPC)
	exporter, err := otlptracegrpc.New(ctx,
		otlptracegrpc.WithEndpoint(collectorEndpoint),
		otlptracegrpc.WithInsecure(), // for local dev; use TLS in production
	)
	if err != nil {
		return nil, err
	}

	// Define resource (service metadata)
	res, err := resource.Merge(
		resource.Default(),
		resource.NewWithAttributes(
			semconv.SchemaURL,
			semconv.ServiceName(serviceName),
			semconv.ServiceVersion("1.0.0"),
			semconv.DeploymentEnvironment("production"),
		),
	)
	if err != nil {
		return nil, err
	}

	// Create TracerProvider with batch span processor
	tp := sdktrace.NewTracerProvider(
		sdktrace.WithBatcher(exporter, sdktrace.WithBatchTimeout(5*time.Second)),
		sdktrace.WithResource(res),
		sdktrace.WithSampler(sdktrace.ParentBased(
			sdktrace.TraceIDRatioBased(0.1), // sample 10%
		)),
	)

	// Register globally
	otel.SetTracerProvider(tp)
	otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
		propagation.TraceContext{}, // W3C Trace Context
		propagation.Baggage{},     // W3C Baggage
	))

	return tp.Shutdown, nil
}

::

HTTP middleware с автоматическим трейсингом:

::code-group

package server

import (
	"context"
	"log/slog"
	"net/http"

	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/codes"
)

func NewRouter() http.Handler {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /api/orders/{id}", getOrder)
	mux.HandleFunc("POST /api/orders", createOrder)

	// Wrap entire mux with OTel HTTP instrumentation
	// Automatically creates spans for every HTTP request
	return otelhttp.NewHandler(mux, "api-server",
		otelhttp.WithMessageEvents(otelhttp.ReadEvents, otelhttp.WriteEvents),
	)
}

func getOrder(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	tracer := otel.Tracer("order-service")

	// Manual child span for business logic
	ctx, span := tracer.Start(ctx, "OrderService.GetOrder")
	defer span.End()

	orderID := r.PathValue("id")
	span.SetAttributes(attribute.String("order.id", orderID))

	// Child span for database query
	order, err := fetchOrderFromDB(ctx, orderID)
	if err != nil {
		span.RecordError(err)
		span.SetStatus(codes.Error, err.Error())
		http.Error(w, "order not found", http.StatusNotFound)
		return
	}

	span.SetAttributes(
		attribute.Float64("order.total", order.Total),
		attribute.String("order.status", order.Status),
	)

	writeJSON(w, order)
}

func fetchOrderFromDB(ctx context.Context, id string) (*Order, error) {
	tracer := otel.Tracer("order-service")
	ctx, span := tracer.Start(ctx, "DB: SELECT order")
	defer span.End()

	span.SetAttributes(
		attribute.String("db.system", "postgresql"),
		attribute.String("db.operation", "SELECT"),
		attribute.String("db.sql.table", "orders"),
	)

	// actual database query here...
	_ = ctx // use ctx for query cancellation

	return &Order{ID: id, Total: 99.99, Status: "pending"}, nil
}

::

Cross-service propagation

Когда Go-сервис вызывает другой сервис, OTel SDK автоматически инжектит trace context в заголовки:

::code-group

package client

import (
	"context"
	"fmt"
	"net/http"

	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

// PaymentClient wraps HTTP client with OTel instrumentation.
// Automatically propagates trace context to downstream services.
type PaymentClient struct {
	httpClient *http.Client
	baseURL    string
}

func NewPaymentClient(baseURL string) *PaymentClient {
	return &PaymentClient{
		// otelhttp.NewTransport wraps http.DefaultTransport:
		// - creates client span for outgoing request
		// - injects W3C traceparent header
		// - records duration and status
		httpClient: &http.Client{
			Transport: otelhttp.NewTransport(http.DefaultTransport),
		},
		baseURL: baseURL,
	}
}

func (c *PaymentClient) Charge(ctx context.Context, amount float64) error {
	// Context carries the current span — otelhttp will create child span
	// and add traceparent header to the outgoing request
	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		fmt.Sprintf("%s/api/charge", c.baseURL), nil)
	if err != nil {
		return err
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("payment failed: status %d", resp.StatusCode)
	}

	return nil
}

::

Metrics — OTel Metrics API

OTel Metrics API определяет типы инструментов, совместимые с Prometheus:

OTel тип Prometheus аналог Описание
Counter Counter Только растёт (кол-во запросов)
UpDownCounter Gauge Растёт и уменьшается (active connections)
Histogram Histogram Распределение значений (latency)
Gauge Gauge Текущее значение (температура, queue size)

PHP: пользовательские метрики

::code-group

<?php

declare(strict_types=1);

namespace App\Service;

use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Metrics\CounterInterface;
use OpenTelemetry\API\Metrics\HistogramInterface;
use OpenTelemetry\API\Metrics\UpDownCounterInterface;

final class MetricsService
{
    private CounterInterface $ordersCreated;
    private HistogramInterface $orderProcessingDuration;
    private UpDownCounterInterface $activeConnections;

    public function __construct()
    {
        $meter = Globals::meterProvider()->getMeter('order-service');

        // Counter: number of created orders
        $this->ordersCreated = $meter->createCounter(
            'orders_created_total',
            'orders',
            'Total number of created orders'
        );

        // Histogram: order processing duration
        $this->orderProcessingDuration = $meter->createHistogram(
            'order_processing_duration_seconds',
            's',
            'Time to process an order'
        );

        // UpDownCounter: active DB connections
        $this->activeConnections = $meter->createUpDownCounter(
            'db_active_connections',
            'connections',
            'Number of active database connections'
        );
    }

    public function recordOrderCreated(string $paymentMethod, string $region): void
    {
        $this->ordersCreated->add(1, [
            'payment_method' => $paymentMethod,
            'region' => $region,
        ]);
    }

    public function recordProcessingDuration(float $seconds, string $orderType): void
    {
        $this->orderProcessingDuration->record($seconds, [
            'order_type' => $orderType,
        ]);
    }

    public function connectionOpened(): void
    {
        $this->activeConnections->add(1);
    }

    public function connectionClosed(): void
    {
        $this->activeConnections->add(-1);
    }
}

::

Go: пользовательские метрики

::code-group

package telemetry

import (
	"context"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/metric"
)

// Metrics holds all application-level OTel metrics instruments.
type Metrics struct {
	OrdersCreated           metric.Int64Counter
	OrderProcessingDuration metric.Float64Histogram
	ActiveConnections       metric.Int64UpDownCounter
	QueueSize               metric.Int64Gauge
}

// NewMetrics creates and registers application metrics.
func NewMetrics() (*Metrics, error) {
	meter := otel.Meter("order-service")

	ordersCreated, err := meter.Int64Counter("orders_created_total",
		metric.WithDescription("Total number of created orders"),
		metric.WithUnit("orders"),
	)
	if err != nil {
		return nil, err
	}

	duration, err := meter.Float64Histogram("order_processing_duration_seconds",
		metric.WithDescription("Time to process an order"),
		metric.WithUnit("s"),
		metric.WithExplicitBucketBoundaries(0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10),
	)
	if err != nil {
		return nil, err
	}

	activeConns, err := meter.Int64UpDownCounter("db_active_connections",
		metric.WithDescription("Active database connections"),
		metric.WithUnit("connections"),
	)
	if err != nil {
		return nil, err
	}

	queueSize, err := meter.Int64Gauge("message_queue_size",
		metric.WithDescription("Current number of messages in queue"),
		metric.WithUnit("messages"),
	)
	if err != nil {
		return nil, err
	}

	return &Metrics{
		OrdersCreated:           ordersCreated,
		OrderProcessingDuration: duration,
		ActiveConnections:       activeConns,
		QueueSize:               queueSize,
	}, nil
}

// RecordOrderCreated increments the orders counter with attributes.
func (m *Metrics) RecordOrderCreated(ctx context.Context, paymentMethod, region string) {
	m.OrdersCreated.Add(ctx, 1,
		metric.WithAttributes(
			attribute.String("payment_method", paymentMethod),
			attribute.String("region", region),
		),
	)
}

::

Logs — корреляция с трейсами

Главная ценность OTel для логов — корреляция: автоматическое добавление trace_id и span_id в каждую запись лога. Это позволяет перейти от лога к трейсу одним кликом в Grafana.

PHP: Monolog processor с trace_id

::code-group

<?php

declare(strict_types=1);

namespace App\Logger;

use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
use OpenTelemetry\API\Trace\Span;
use OpenTelemetry\Context\Context;

/**
 * Adds OpenTelemetry trace_id and span_id to every log record.
 * Enables jumping from log entry to full trace in Grafana.
 */
final class TraceContextProcessor implements ProcessorInterface
{
    public function __invoke(LogRecord $record): LogRecord
    {
        $span = Span::fromContext(Context::getCurrent());
        $spanContext = $span->getContext();

        if (!$spanContext->isValid()) {
            return $record;
        }

        return $record->with(
            extra: array_merge($record->extra, [
                'trace_id' => $spanContext->getTraceId(),
                'span_id' => $spanContext->getSpanId(),
                'trace_flags' => $spanContext->getTraceFlags(),
            ]),
        );
    }
}

::

Конфигурация Monolog в Symfony:

# config/packages/monolog.yaml
monolog:
    handlers:
        main:
            type: stream
            path: "%kernel.logs_dir%/%kernel.environment%.log"
            level: debug
            formatter: 'monolog.formatter.json'
            channels: ["!event"]
    processors:
        - App\Logger\TraceContextProcessor

Результат — каждая запись лога содержит trace_id:

{
  "message": "Order processed successfully",
  "context": {"order_id": 12345, "total": 99.99},
  "level": 200,
  "level_name": "INFO",
  "channel": "app",
  "datetime": "2026-03-01T10:23:45.123456+00:00",
  "extra": {
    "trace_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
    "span_id": "00f067aa0ba902b7",
    "trace_flags": 1
  }
}

Go: slog с trace_id

::code-group

package logger

import (
	"context"
	"log/slog"

	"go.opentelemetry.io/otel/trace"
)

// TraceHandler wraps slog.Handler to inject trace context into every log.
type TraceHandler struct {
	inner slog.Handler
}

func NewTraceHandler(inner slog.Handler) *TraceHandler {
	return &TraceHandler{inner: inner}
}

func (h *TraceHandler) Enabled(ctx context.Context, level slog.Level) bool {
	return h.inner.Enabled(ctx, level)
}

func (h *TraceHandler) Handle(ctx context.Context, record slog.Record) error {
	// Extract span context from ctx
	spanCtx := trace.SpanContextFromContext(ctx)
	if spanCtx.IsValid() {
		record.AddAttrs(
			slog.String("trace_id", spanCtx.TraceID().String()),
			slog.String("span_id", spanCtx.SpanID().String()),
		)
	}

	return h.inner.Handle(ctx, record)
}

func (h *TraceHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
	return &TraceHandler{inner: h.inner.WithAttrs(attrs)}
}

func (h *TraceHandler) WithGroup(name string) slog.Handler {
	return &TraceHandler{inner: h.inner.WithGroup(name)}
}

::

Использование:

// Setup
jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
logger := slog.New(logger.NewTraceHandler(jsonHandler))
slog.SetDefault(logger)

// In request handler — ctx carries the active span
slog.InfoContext(ctx, "order processed",
    slog.Int("order_id", 12345),
    slog.Float64("total", 99.99),
)

// Output:
// {"time":"2026-03-01T10:23:45Z","level":"INFO","msg":"order processed",
//  "order_id":12345,"total":99.99,
//  "trace_id":"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6","span_id":"00f067aa0ba902b7"}

OTel Collector Configuration

Полная конфигурация Collector с pipelines для всех трёх сигналов:

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

  # Scrape Prometheus targets (optional — hybrid mode)
  prometheus:
    config:
      scrape_configs:
        - job_name: 'otel-collector'
          scrape_interval: 15s
          static_configs:
            - targets: ['localhost:8888']

processors:
  # Group spans/metrics/logs into batches for efficient export
  batch:
    send_batch_size: 1024
    send_batch_max_size: 2048
    timeout: 5s

  # Protect Collector from OOM
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
    spike_limit_mib: 128

  # Add common attributes to all telemetry
  attributes:
    actions:
      - key: environment
        value: production
        action: upsert
      - key: cluster
        value: eu-west-1
        action: upsert

  # Tail-based sampling: keep all error traces, sample 10% of success
  tail_sampling:
    decision_wait: 10s
    num_traces: 100000
    policies:
      - name: errors-always
        type: status_code
        status_code: {status_codes: [ERROR]}
      - name: slow-traces
        type: latency
        latency: {threshold_ms: 2000}
      - name: percentage-sample
        type: probabilistic
        probabilistic: {sampling_percentage: 10}

exporters:
  # Traces → Grafana Tempo
  otlp/tempo:
    endpoint: tempo:4317
    tls:
      insecure: true

  # Metrics → Prometheus (Collector exposes /metrics endpoint)
  prometheus:
    endpoint: "0.0.0.0:8889"
    resource_to_telemetry_conversion:
      enabled: true

  # Logs → Grafana Loki
  loki:
    endpoint: http://loki:3100/loki/api/v1/push
    labels:
      attributes:
        service.name: "service"
        level: "severity"

  # Debug: print to stdout (dev only)
  debug:
    verbosity: detailed

service:
  telemetry:
    logs:
      level: info
    metrics:
      address: 0.0.0.0:8888

  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, tail_sampling, batch, attributes]
      exporters: [otlp/tempo]

    metrics:
      receivers: [otlp, prometheus]
      processors: [memory_limiter, batch, attributes]
      exporters: [prometheus]

    logs:
      receivers: [otlp]
      processors: [memory_limiter, batch, attributes]
      exporters: [loki]

Docker Compose для local development

# docker-compose.observability.yaml
services:
  # OTel Collector — central telemetry pipeline
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.96.0
    command: ["--config", "/etc/otel-collector-config.yaml"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro
    ports:
      - "4317:4317"   # OTLP gRPC
      - "4318:4318"   # OTLP HTTP
      - "8889:8889"   # Prometheus metrics
    depends_on:
      tempo:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:13133/"]
      interval: 10s
      timeout: 5s
      retries: 3

  # Grafana Tempo — trace storage
  tempo:
    image: grafana/tempo:2.4.0
    command: ["-config.file=/etc/tempo.yaml"]
    volumes:
      - ./tempo.yaml:/etc/tempo.yaml:ro
      - tempo-data:/var/tempo
    ports:
      - "3200:3200"   # Tempo query API
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3200/ready"]
      interval: 10s
      timeout: 5s
      retries: 3

  # Prometheus — metrics storage
  prometheus:
    image: prom/prometheus:v2.50.0
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus-data:/prometheus
    ports:
      - "9090:9090"
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.retention.time=15d'
      - '--web.enable-remote-write-receiver'
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/-/ready"]
      interval: 10s
      timeout: 5s
      retries: 3

  # Grafana Loki — log aggregation
  loki:
    image: grafana/loki:2.9.4
    ports:
      - "3100:3100"
    volumes:
      - loki-data:/loki
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3100/ready"]
      interval: 10s
      timeout: 5s
      retries: 3

  # Grafana — visualization
  grafana:
    image: grafana/grafana:10.3.0
    ports:
      - "3000:3000"
    environment:
      GF_AUTH_ANONYMOUS_ENABLED: "true"
      GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
    volumes:
      - grafana-data:/var/lib/grafana
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
    depends_on:
      - prometheus
      - tempo
      - loki

volumes:
  tempo-data:
  prometheus-data:
  loki-data:
  grafana-data:

Best Practices

Стратегии сэмплирования

Стратегия Описание Когда использовать
Always On Записывать 100% трейсов Dev/staging, дебаг
Head-based Решение о сэмплировании в начале трейса Основной подход для production
Tail-based Решение после завершения трейса (Collector) Когда нужны ВСЕ ошибки + % success
Parent-based Наследовать решение родительского span Кросс-сервисная согласованность

Tail-based sampling — самый мощный подход: Collector видит весь трейс целиком и может решить сохранить его, если хоть один span содержит ошибку или высокий latency.

Semantic Conventions

OTel определяет стандартные имена атрибутов. Используйте их вместо произвольных:

Атрибут Описание
http.method HTTP метод (GET, POST)
http.status_code HTTP код ответа
http.route Шаблон маршрута (/api/orders/{id})
db.system Тип БД (postgresql, redis, mongodb)
db.operation Операция (SELECT, INSERT, UPDATE)
db.sql.table Имя таблицы
messaging.system Система сообщений (rabbitmq, kafka)
messaging.operation Операция (publish, receive)
rpc.system RPC система (grpc)
rpc.method Имя метода

Performance overhead

Компонент Overhead Как минимизировать
SDK (auto-instrumentation) 1-3% CPU Сэмплирование, batch processing
Context propagation <0.1ms на запрос Уже минимально
Collector 100-500MB RAM memory_limiter, batch processor
Network (OTLP gRPC) <1% bandwidth Compression, batch export

Правило: OTel overhead не должен превышать 2% от базового latency. Если превышает — увеличьте интервал batch export или снизьте sampling rate.

Проверь себя

5 из 8
🧪

Какой OTel metric type подходит для отслеживания количества активных подключений к базе данных?

🧪

Какой HTTP-заголовок используется для передачи trace context между сервисами по стандарту W3C?

🧪

Какой максимальный overhead от OpenTelemetry SDK считается приемлемым для production?

🧪

Чем tail-based sampling отличается от head-based?

🧪

Какую проблему решает OpenTelemetry в первую очередь?