Опирается на правила:
R-OBS-TRC-1…R-OBS-TRC-6иR-OBS-TRC-X1…R-OBS-TRC-X4из Observability Style Guide → раздел 3. Tracing.
Важно знать
- OTel автоинструментация через
otelhttp.NewHandlerна chi-роутере даёт auto-spans для каждого HTTP-запроса.traceparentW3C Trace Context пробрасывается входящим middleware и исходящимotelhttp.NewTransportавтоматически.- pgx и HTTP-клиент инструментируются через
otelpgxиotelhttp.NewTransport— без ручного кода в queries.- Manual spans для UseCase-хендлеров:
otel.Tracer("…").Start(ctx, "…")+ обязательныйdefer span.End().- Span attributes — business context (
order.id,payment.method), не PII: неcustomer.email, неcard.pan.- Sampling —
TraceIDRatioBased(0.01)вParentBasedв проде + tail-based для ошибок в OTel Collector.trace_id/span_idв логах — автоматически через OTel-slog bridge; руками не добавлять.- Горутина с
context.Background()разрывает trace — пробрасывай родительскийctxаргументом.
Tracing — третья нога observability. Когда метрики говорят «p95 вырос», а логи — «много ошибок в product-service», trace показывает конкретный запрос сквозь сервисы: где 180ms провели в pgx, где 900ms в HTTP-вызове в Sber API, где упало. В Go нет авто-инструментации уровня javaagent, но contrib-пакеты покрывают net/http, pgx/v5, kafka-go одной строкой подключения.
Настройка TracerProvider
R-OBS-TRC-1: инициализация в internal/platform/tracing/setup.go.
package tracing
import (
"context"
"fmt"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
type Config struct {
OTLPEndpoint string
ServiceName string
Version string
Env string
SampleRate float64
}
func Setup(ctx context.Context, cfg Config) (func(context.Context) error, error) {
exp, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint(cfg.OTLPEndpoint),
otlptracegrpc.WithInsecure(),
)
if err != nil {
return nil, fmt.Errorf("tracing exporter: %w", err)
}
res, _ := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(cfg.ServiceName),
semconv.ServiceVersion(cfg.Version),
semconv.DeploymentEnvironment(cfg.Env),
),
)
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithSampler(
sdktrace.ParentBased(sdktrace.TraceIDRatioBased(cfg.SampleRate)),
),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
return tp.Shutdown, nil
}
ParentBased уважает решение вышестоящего сервиса: если входящий traceparent помечен sampled=1, дочерний сервис тоже включается в trace независимо от своего ratio. Для low-traffic сервисов (<10 req/s) SampleRate: 1.0 — storage не перегружается.
Инициализация и graceful shutdown в main:
// cmd/order-service/main.go
shutdown, err := tracing.Setup(ctx, tracing.Config{
OTLPEndpoint: cfg.OTLPEndpoint,
ServiceName: "order-service",
Version: version,
Env: cfg.AppEnv,
SampleRate: 0.01,
})
if err != nil {
log.Fatal("tracing setup", slog.String("error", err.Error()))
}
defer func() { _ = shutdown(ctx) }()
Автоинструментация chi-роутера и HTTP-клиента
R-OBS-TRC-1, R-OBS-TRC-2: оборачиваем роутер и Transport.
// cmd/order-service/main.go
router := chi.NewRouter()
router.Use(middleware.RequestID)
router.Use(otelchi.Middleware("order-service", otelchi.WithChiRoutes(router)))
router.Use(chiMiddleware.Logger)
// ... регистрация маршрутов
businessSrv := &http.Server{
Addr: cfg.Addr,
Handler: router,
}
Для исходящего HTTP-клиента (вызов Sber API или product-service):
// internal/platform/httpclient/client.go
func New() *http.Client {
return &http.Client{
Transport: otelhttp.NewTransport(http.DefaultTransport),
Timeout: 10 * time.Second,
}
}
otelhttp.NewTransport автоматически добавляет traceparent в исходящие заголовки — сервис-получатель видит trace и создаёт child span без дополнительного кода.
Инструментация pgx
R-OBS-TRC-1: otelpgx подключается к пулу соединений.
// internal/platform/postgres/pool.go
import "go.opentelemetry.io/contrib/instrumentation/github.com/jackc/pgx/v5/otelpgx"
func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parse dsn: %w", err)
}
cfg.ConnConfig.Tracer = otelpgx.NewTracer()
return pgxpool.NewWithConfig(ctx, cfg)
}
После этого каждый SQL-запрос через pgx создаёт дочерний span с атрибутами db.statement, db.operation, db.name — видно в Tempo без единой строки в прикладном коде.
Manual spans для UseCase-хендлеров
R-OBS-TRC-3: хендлеры добавляют named span с бизнес-атрибутами.
// internal/order/usecase/confirm_order.go
package usecase
import (
"context"
"fmt"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
type ConfirmOrderHandler struct {
orders OrderRepository
sberPay SberPayGateway
}
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) error {
ctx, span := otel.Tracer("order").Start(ctx, "ConfirmOrder")
defer span.End()
span.SetAttributes(
attribute.String("order.id", cmd.OrderID),
attribute.String("payment.method", string(cmd.PaymentMethod)),
)
order, err := h.orders.Load(ctx, cmd.OrderID)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("load order: %w", err)
}
if err = order.Confirm(); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("confirm order: %w", err)
}
span.SetAttributes(attribute.String("order.status", string(order.Status)))
if err = h.orders.Save(ctx, order); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("save order: %w", err)
}
return nil
}
defer span.End() — идиоматичный Go-эквивалент Java try-finally. В Go нет try-with-resources, defer — единственный надёжный гарант закрытия span при любом пути выполнения, включая panic.
Аналогичный паттерн для поиска продуктов:
// internal/product/usecase/search_products.go
func (h *SearchProductsHandler) Handle(ctx context.Context, q SearchProductsQuery) ([]Product, error) {
ctx, span := otel.Tracer("product").Start(ctx, "SearchProducts")
defer span.End()
span.SetAttributes(
attribute.String("search.query", q.Query),
attribute.Int("search.limit", q.Limit),
)
products, err := h.catalog.Search(ctx, q)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, fmt.Errorf("catalog search: %w", err)
}
span.SetAttributes(attribute.Int("search.results", len(products)))
return products, nil
}
Span attributes — business context, не PII
R-OBS-TRC-4: tracing-хранилище (Tempo, Jaeger) имеет другой режим доступа и другой retention. PII там — нарушение compliance.
| Разрешено | Запрещено |
|---|---|
order.id, customer.id (внутренние ID) | customer.email, customer.phone |
order.status, payment.method (enum) | card.pan, card.cvv |
product.id, product.sku | product.description (может содержать ПД) |
sber.request_id | sber.api_key, sber.session_token |
circuit_breaker.state | полный JSON тела запроса |
Внутренние UUID-идентификаторы (order.id, customer.id) — норма: они нужны для навигации к записи в основной БД.
Sampling
R-OBS-TRC-5: 1-10% в проде через TraceIDRatioBased.
В Go SDK sampling-стратегия задаётся при создании TracerProvider — одна строка конфигурации (SampleRate: 0.01). В prod-конфиге нагруженного сервиса достаточно 1%: 100 req/s → 1 trace/s → ~86K traces/день, Tempo справляется без затрат.
Tail-based sampling настраивается в OTel Collector вне Go-кода:
# otel-collector/config.yaml (фрагмент)
processors:
tail_sampling:
decision_wait: 10s
policies:
- name: errors-policy
type: status_code
status_code: {status_codes: [ERROR]}
- name: slow-traces-policy
type: latency
latency: {threshold_ms: 1000}
- name: probabilistic-policy
type: probabilistic
probabilistic: {sampling_percentage: 1}
Политика errors-policy гарантирует 100% сохранение traces с ошибками — error-traces не попадают под ratio-обрезку.
TraceId в логах через slog bridge
R-OBS-TRC-6: go.opentelemetry.io/contrib/bridges/otelslog автоматически добавляет trace_id/span_id в каждую slog-запись, если в контексте есть активный span.
// internal/platform/log/setup.go
import "go.opentelemetry.io/contrib/bridges/otelslog"
func New(env string) *slog.Logger {
var handler slog.Handler
if env == "production" {
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
} else {
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
}
return slog.New(otelslog.NewHandler("order-service", otelslog.WithLoggerProvider(otel.GetLoggerProvider()),
otelslog.WithSource(true),
))
}
Если OTel logger provider не нужен отдельно, достаточно пробрасывать ctx в slog-методы — bridge извлекает trace_id из активного span в контексте:
// span активен в ctx после otel.Tracer(...).Start(ctx, ...)
h.log.InfoContext(ctx, "order_confirmed",
slog.String("order_id", cmd.OrderID),
slog.String("customer_id", order.CustomerID),
)
JSON в Loki будет содержать "trace_id": "5e92c8a3b1f4d2e6…" — клик → Tempo → весь distributed trace.
Горутины и разрыв trace
R-OBS-TRC-X4 / R-OBS-CTX-X3: горутина, стартующая с context.Background(), создаёт отдельный root span — trace разрывается, parent связь теряется.
// ПЛОХО — trace разорван
go func() {
h.notify(context.Background(), order.CustomerID)
}()
// ХОРОШО — пробрасываем родительский ctx аргументом
notifyCtx, notifyCancel := context.WithTimeout(ctx, 5*time.Second)
defer notifyCancel()
go func(ctx context.Context) {
if err := h.notify(ctx, order.CustomerID); err != nil {
h.log.WarnContext(ctx, "customer_notify_failed",
slog.String("customer_id", order.CustomerID),
slog.String("error", err.Error()),
)
}
}(notifyCtx)
Передача ctx аргументом (не захват из замыкания) — защита от случая, когда родительская горутина отменит ctx раньше, чем дочерняя дочитает данные.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
sdktrace.AlwaysSample() в проде | R-OBS-TRC-X1 | TraceIDRatioBased(0.01) + tail-based в коллекторе |
PII в span attributes (customer.email, card.pan) | R-OBS-TRC-X2 | только внутренние ID и enum-значения |
Manual span без defer span.End() | R-OBS-TRC-X3 | всегда defer span.End() сразу после Start |
Горутина с context.Background() | R-OBS-TRC-X4 | пробрасывать родительский ctx аргументом |
| Прямой Zipkin / Jaeger client вместо OTel | R-OBS-TRC-1 | go.opentelemetry.io/otel — отраслевой стандарт |
HTTP-клиент без otelhttp.NewTransport | R-OBS-TRC-2 | otelhttp.NewTransport добавляет traceparent |
trace_id руками через ctx.Value(...) в slog | R-OBS-TRC-6 | OTel-slog bridge добавляет автоматически |
Куда дальше
- Logging —
slog, OTel-slog bridge, связкаtrace_idс log-записью. - Context propagation —
context.Contextкак единственный механизм propagation; горутины и fan-out. - Metrics —
prometheus/client_golang, почему high-cardinality не для метрик. - Конфигурация — два HTTP-сервера, management-порт, управление уровнем логов.
- Health checks —
/health/live,/health/ready, TTL-кеш для внешних проверок. - SLO и алерты — multi-window burn-rate alerts, error budget.