Урок 26: Трассировка с OpenTelemetry

Урок 26. Трассировка с OpenTelemetry

🔄 Node.js → Go (ключевые аналоги):

📋 Что изучаем

📦 Инициализация проекта

mkdir go-tracing && cd go-tracing
go mod init go-tracing

# Устанавливаем OpenTelemetry
go get go.opentelemetry.io/otel
go get go.opentelemetry.io/otel/sdk
go get go.opentelemetry.io/otel/sdk/trace
go get go.opentelemetry.io/otel/exporters/jaeger
go get go.opentelemetry.io/otel/exporters/stdout/stdouttrace
go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
go get go.opentelemetry.io/otel/attribute
go get go.opentelemetry.io/otel/codes
go mod tidy

# Запускаем Jaeger (all-in-one)
docker run -d --name jaeger \
  -e COLLECTOR_OTLP_ENABLED=true \
  -p 16686:16686 \
  -p 14268:14268 \
  -p 4317:4317 \
  -p 4318:4318 \
  jaegertracing/all-in-one:latest

💻 Файл: internal/tracing/tracing.go

package tracing

import (
    "context"
    "fmt"
    "log"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/codes"
    "go.opentelemetry.io/otel/exporters/jaeger"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.17.0"
)

// Config — конфигурация трассировки
type Config struct {
    ServiceName string
    Environment string
    JaegerURL   string // http://localhost:14268/api/traces
}

// InitTracer — инициализирует глобальный трейсер
func InitTracer(cfg Config) (*sdktrace.TracerProvider, error) {
    // 1. Создаём экспортер в Jaeger
    exporter, err := jaeger.New(
        jaeger.WithCollectorEndpoint(
            jaeger.WithEndpoint(cfg.JaegerURL),
        ),
    )
    if err != nil {
        return nil, fmt.Errorf("create jaeger exporter: %w", err)
    }

    // 2. Создаём ресурс (метаданные сервиса)
    res := resource.NewWithAttributes(
        semconv.SchemaURL,
        semconv.ServiceName(cfg.ServiceName),
        semconv.DeploymentEnvironment(cfg.Environment),
        attribute.String("version", "1.0.0"),
    )

    // 3. Создаём TracerProvider
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(res),
        // Sampler: AlwaysSample (для dev), TraceIDRatioBased (для prod)
        sdktrace.WithSampler(sdktrace.AlwaysSample()),
    )

    // 4. Устанавливаем глобальный TracerProvider
    otel.SetTracerProvider(tp)

    log.Printf("✅ Трассировка настроена: сервис=%s, env=%s", cfg.ServiceName, cfg.Environment)
    return tp, nil
}

// Shutdown — корректное завершение (отправка оставшихся спанов)
func Shutdown(ctx context.Context, tp *sdktrace.TracerProvider) error {
    if err := tp.Shutdown(ctx); err != nil {
        return fmt.Errorf("shutdown tracer: %w", err)
    }
    log.Println("✅ Трассировка завершена")
    return nil
}

// ╔══════════════════════════════════════════════════════════╗
// ║  ХЕЛПЕРЫ ДЛЯ СОЗДАНИЯ СПАНОВ                          ║
// ╚══════════════════════════════════════════════════════════╝

// StartDBSpan — создаёт спан для запроса к БД
func StartDBSpan(ctx context.Context, operation, query string) (context.Context, Span) {
    tracer := otel.Tracer("database")
    ctx, span := tracer.Start(ctx, fmt.Sprintf("DB %s", operation))

    span.SetAttributes(
        attribute.String("db.system", "postgresql"),
        attribute.String("db.operation", operation),
        attribute.String("db.statement", query),
    )
    return ctx, &otelSpan{span: span}
}

// StartCacheSpan — создаёт спан для запроса к кэшу
func StartCacheSpan(ctx context.Context, operation, key string) (context.Context, Span) {
    tracer := otel.Tracer("cache")
    ctx, span := tracer.Start(ctx, fmt.Sprintf("Cache %s", operation))

    span.SetAttributes(
        attribute.String("cache.system", "redis"),
        attribute.String("cache.operation", operation),
        attribute.String("cache.key", key),
    )
    return ctx, &otelSpan{span: span}
}

// StartExternalSpan — создаёт спан для внешнего API-запроса
func StartExternalSpan(ctx context.Context, method, url string) (context.Context, Span) {
    tracer := otel.Tracer("external")
    ctx, span := tracer.Start(ctx, fmt.Sprintf("HTTP %s %s", method, url))

    span.SetAttributes(
        attribute.String("http.method", method),
        attribute.String("http.url", url),
    )
    return ctx, &otelSpan{span: span}
}

// ╔══════════════════════════════════════════════════════════╗
// ║  Span — обёртка для удобства                           ║
// ╚══════════════════════════════════════════════════════════╝

type Span interface {
    End()
    RecordError(err error)
    SetAttributes(attrs ...attribute.KeyValue)
    AddEvent(name string, attrs ...attribute.KeyValue)
}

type otelSpan struct {
    span oteltrace.Span
}

func (s *otelSpan) End() {
    s.span.End()
}

func (s *otelSpan) RecordError(err error) {
    s.span.RecordError(err)
    s.span.SetStatus(codes.Error, err.Error())
}

func (s *otelSpan) SetAttributes(attrs ...attribute.KeyValue) {
    s.span.SetAttributes(attrs...)
}

func (s *otelSpan) AddEvent(name string, attrs ...attribute.KeyValue) {
    s.span.AddEvent(name, oteltrace.WithAttributes(attrs...))
}

import oteltrace "go.opentelemetry.io/otel/trace"

💻 Файл: cmd/server/main.go

package main

import (
    "context"
    "errors"
    "fmt"
    "log"
    "math/rand"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

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

func main() {
    log.SetFlags(log.LstdFlags | log.Lmicroseconds)
    log.Println("🔍 Запуск сервера с трассировкой...")

    // 1. Инициализируем трассировку
    tp, err := tracing.InitTracer(tracing.Config{
        ServiceName: "user-service",
        Environment: "development",
        JaegerURL:   "http://localhost:14268/api/traces",
    })
    if err != nil {
        log.Fatalf("Трассировка: %v", err)
    }
    defer func() {
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        tracing.Shutdown(ctx, tp)
    }()

    // 2. Создаём HTTP-обработчики
    mux := http.NewServeMux()

    mux.HandleFunc("GET /api/users/{id}", getUserHandler)

    // 3. Оборачиваем ВЕСЬ роутер в otelhttp (автоматические спаны!)
    handler := otelhttp.NewHandler(mux, "user-service",
        otelhttp.WithSpanNameFormatter(func(operation string, r *http.Request) string {
            return fmt.Sprintf("%s %s", r.Method, r.URL.Path)
        }),
    )

    server := &http.Server{Addr: ":8080", Handler: handler}

    go func() {
        log.Println("✅ Сервер на http://localhost:8080")
        log.Fatal(server.ListenAndServe())
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    log.Println("🛑 Выключение...")
    server.Shutdown(context.Background())
}

// getUserHandler — обработчик с РУЧНОЙ трассировкой
func getUserHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    id := r.PathValue("id")

    // Получаем трейсер (автоматически создан otelhttp)
    tracer := otel.Tracer("user-service")

    // Создаём дочерний спан
    ctx, span := tracer.Start(ctx, "getUserHandler")
    defer span.End()

    span.SetAttributes(
        attribute.String("user.id", id),
        attribute.String("handler.type", "http"),
    )

    // 1. Проверка кэша (дочерний спан)
    user, fromCache, err := getFromCacheOrDB(ctx, id)
    if err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    if fromCache {
        span.AddEvent("cache_hit", attribute.String("user.id", id))
    }

    // 2. Обогащение данных (дочерний спан)
    enrichCtx, enrichSpan := tracer.Start(ctx, "enrichUserData")
    enrichSpan.SetAttributes(attribute.String("user.id", id))
    time.Sleep(time.Duration(rand.Intn(50)) * time.Millisecond) // Имитация
    enrichSpan.End()
    _ = enrichCtx

    w.Header().Set("Content-Type", "application/json")
    fmt.Fprintf(w, `{"user":"%s","from_cache":%v}`, user, fromCache)
}

// getFromCacheOrDB — имитация запроса к кэшу и БД
func getFromCacheOrDB(ctx context.Context, id string) (string, bool, error) {
    // Спан для проверки кэша
    cacheCtx, cacheSpan := tracing.StartCacheSpan(ctx, "GET", fmt.Sprintf("user:%s", id))
    defer cacheSpan.End()

    // Имитация: 70% — cache hit
    if rand.Float64() < 0.7 {
        cacheSpan.SetAttributes(attribute.Bool("cache.hit", true))
        return fmt.Sprintf("User-%s", id), true, nil
    }

    cacheSpan.SetAttributes(attribute.Bool("cache.hit", false))
    _ = cacheCtx

    // Cache miss — идём в БД
    dbCtx, dbSpan := tracing.StartDBSpan(ctx, "SELECT",
        fmt.Sprintf("SELECT * FROM users WHERE id = '%s'", id))
    defer dbSpan.End()

    // Имитация запроса к БД
    time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)

    if rand.Float64() < 0.1 {
        err := errors.New("database timeout")
        dbSpan.RecordError(err)
        return "", false, err
    }

    dbSpan.AddEvent("row_found", attribute.String("user.id", id))
    _ = dbCtx

    return fmt.Sprintf("User-%s", id), false, nil
}

💻 Файл: cmd/client/main.go — HTTP-клиент с трассировкой

package main

import (
    "context"
    "fmt"
    "io"
    "log"
    "net/http"
    "time"

    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go-tracing/internal/tracing"
)

func main() {
    // Инициализируем трассировку
    tp, _ := tracing.InitTracer(tracing.Config{
        ServiceName: "api-gateway",
        Environment: "development",
        JaegerURL:   "http://localhost:14268/api/traces",
    })
    defer tp.Shutdown(context.Background())

    // HTTP-клиент с автоматической трассировкой
    client := &http.Client{
        Transport: otelhttp.NewTransport(http.DefaultTransport),
    }

    tracer := otel.Tracer("api-gateway")

    for i := 0; i < 5; i++ {
        ctx, span := tracer.Start(context.Background(), "fetchUserFromGateway")
        span.SetAttributes(attribute.Int("iteration", i+1))

        userID := fmt.Sprintf("%d", i+1)
        url := fmt.Sprintf("http://localhost:8080/api/users/%s", userID)

        req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
        resp, err := client.Do(req)
        if err != nil {
            span.RecordError(err)
            log.Printf("❌ Ошибка: %v", err)
            span.End()
            continue
        }

        body, _ := io.ReadAll(resp.Body)
        resp.Body.Close()
        log.Printf("%s", string(body))
        span.End()

        time.Sleep(500 * time.Millisecond)
    }

    log.Println("✅ Клиент завершил работу")
}

🚀 Запуск и просмотр трейсов

# Запускаем Jaeger
docker run -d --name jaeger \
  -e COLLECTOR_OTLP_ENABLED=true \
  -p 16686:16686 \
  -p 14268:14268 \
  jaegertracing/all-in-one:latest

# Запускаем сервер
go run ./cmd/server/main.go

# Запускаем клиент (в другом терминале)
go run ./cmd/client/main.go

# Открываем Jaeger UI
open http://localhost:16686

# Выбираем сервис "user-service" и смотрим трейсы!

📊 OpenTelemetry в Go

КомпонентПакетНазначение
TracerProvidergo.opentelemetry.io/otel/sdk/traceУправление трейсерами
Exporter (Jaeger).../exporters/jaegerОтправка трейсов в Jaeger
Exporter (OTLP).../exporters/otlp/otlptraceОтправка трейсов по OTLP (gRPC)
otelhttp.../instrumentation/net/http/otelhttpАвто-инструментация HTTP
Samplersdktrace.AlwaysSample()Какие трейсы сохранять
Resourceresource.NewWithAttributes()Метаданные сервиса
⚠️ Типичные ошибки:
💡 Практический совет:

💡 Best practices от сеньоров:

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

Что нужно запомнить из этого урока:

💡 Для Node.js разработчика:

← Предыдущий урок Следующий урок →