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.