Jaeger

В данном практическом занятии познакомимся базовому взаимодействию с инструментом трассировки jaeger.

Vagrant

Vagrant.configure("2") do |config|
  config.vm.define "jaeger" do |c|
    c.vm.box = "ubuntu/lunar64"
    c.vm.hostname = "jaeger"
    c.vm.network "forwarded_port", guest: 8888, host: 8888
    c.vm.network "forwarded_port", guest: 8889, host: 8889
    c.vm.provision "shell", inline: <<-SHELL
      apt-get update -q
      apt-get install -yq docker.io docker-compose-v2
      usermod -a -G docker vagrant
    SHELL
  end
end

Данная конфигурация установит на виртуальную машину docker и docker compose, с помощью которых в дальнейшем будут развернуты остальные компоненты.

Jaeger

Развернем jaeger с помощью следующего compose.yaml:

services:
  jaeger:
    container_name: jaeger
    image: jaegertracing/all-in-one
    ports:
      - "8889:14268"
      - "8888:16686"
$ docker compose up -d
[+] Running 2/2
 ✔ Network vagrant_default  Created                                                   0.1s
 ✔ Container jaeger         Started                                                   0.3s

После чего по адресу localhost:8888/search будет доступен интерфейс.

Simple Trace

Создадим простое приложение, которое будет отправлять трейсы в jaeger:

package main

import (
        "context"
        "errors"
        "log"
        "time"

        "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.24.0"
        "go.opentelemetry.io/otel/trace"
        "google.golang.org/grpc"
        "google.golang.org/grpc/credentials/insecure"
)

const (
        ServiceName = "service"
)

var (
        prv *sdktrace.TracerProvider
        tr trace.Tracer
        errSpan = errors.New("span error")
)

func initTracer(ctx context.Context) error{
        conn, err := grpc.NewClient("jaeger:4317",
                grpc.WithTransportCredentials(insecure.NewCredentials()),
        )
        if err != nil {
                return err
        }

        exp, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn))
        if err != nil {
                return err
        }

        res, err := resource.New(ctx,
                resource.WithAttributes(
                        semconv.ServiceName(ServiceName),
                ),
        )
        if err != nil {
                return err
        }

        prv = sdktrace.NewTracerProvider(
                sdktrace.WithBatcher(exp),
                sdktrace.WithResource(res),
        )

        otel.SetTracerProvider(prv)
        tr = prv.Tracer("tracer")

        return nil
}

func main() {
        ctx := context.Background()
        if err := initTracer(ctx);err != nil {
                log.Fatal("init tracer", err)
        }

        ctx, span := tr.Start(ctx, "span")
        time.Sleep(time.Second)
        span.End()

        if err := prv.Shutdown(ctx); err != nil {
                log.Fatal("failed shutdown", err)
        }
}

Данное приложение инициализирует отправку трейсов: создает grpc подключение, атрибуты, провайдер и экспортер. После чего создает спан и после ожидания закрывает его.

Добавим Dockerfile для него:

FROM golang:1.21 as build

WORKDIR /src
COPY main.go /src/main.go
RUN go mod init example \
  && go mod tidy
RUN CGO_ENABLED=0 go build -o /bin/app ./main.go

FROM scratch
COPY --from=build /bin/app /app
CMD ["/app"]

А также обновим compose.yaml:

services:
  jaeger:
    container_name: jaeger
    image: jaegertracing/all-in-one
    ports:
      - "8889:14268"
      - "8888:16686"
  app:
    container_name: test
    image: test
    build: .

После чего запустим:

$ docker compose up -d --build
[+] Running 2/2
 ✔ Container test    Started                                                          0.4s
 ✔ Container jaeger  Running                                                          0.0s

Когда приложение отработает, то в интерфейсе jaeger можно будет увидеть наш новый сервис с названием service:

Нажав Find Traces можем увидеть список последних трейсов:

А кликнув по конкретному трейсу увидим подробности о нем:

Наш трейс состоит из одного спана, кликнув по нему можно узнать подробности и о нем:

Multiple Spans

Добавим в наше приложение генерацию нескольких спанов, через которые можно отслеживать выполнение нескольких операций. Для этого напишем функцию test, которая при вызове будет создавать новый спан используя контекст корневого и выполняться со случайной задержкой:

package main

import (
        "context"
        "errors"
        "log"
        "time"
        "fmt"
        "math/rand"

        "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.24.0"
        "go.opentelemetry.io/otel/trace"
        "google.golang.org/grpc"
        "google.golang.org/grpc/credentials/insecure"
)

const (
        ServiceName = "service"
)

var (
        prv *sdktrace.TracerProvider
        tr trace.Tracer
        errSpan = errors.New("span error")
)

func initTracer(ctx context.Context) error{
        conn, err := grpc.NewClient("jaeger:4317",
                grpc.WithTransportCredentials(insecure.NewCredentials()),
        )
        if err != nil {
                return err
        }

        exp, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn))
        if err != nil {
                return err
        }

        res, err := resource.New(ctx,
                resource.WithAttributes(
                        semconv.ServiceName(ServiceName),
                ),
        )
        if err != nil {
                return err
        }

        prv = sdktrace.NewTracerProvider(
                sdktrace.WithBatcher(exp),
                sdktrace.WithResource(res),
        )

        otel.SetTracerProvider(prv)
        tr = prv.Tracer("tracer")

        return nil
}

func main() {
        ctx := context.Background()
        if err := initTracer(ctx);err != nil {
                log.Fatal("init tracer", err)
        }

        ctx, span := tr.Start(ctx, "root span")
        test(ctx, 1)
        test(ctx, 2)
        span.End()

        if err := prv.Shutdown(ctx); err != nil {
                log.Fatal("failed shutdown", err)
        }
}

func test(ctx context.Context, count int) {
        ctx, span := tr.Start(ctx, fmt.Sprintf("span-%d", count))
        defer span.End()
        num := rand.Intn(5)+1
        time.Sleep(time.Duration(num)*time.Second)
}

И запустим:

$ docker compose up -d --build
[+] Running 2/2
 ✔ Container jaeger  Running                                                          0.0s
 ✔ Container test    Started                                                          0.5s

После чего в jaeger увидим новый трейс:

В результате видно какое время выполнялась каждая функция.

Error Span

Как видно было при детальном рассмотрении информации о спане - в нем можно хранить множество дополнительных атрибутов. Одним из удобных объектов для хранения в спане - это информация об ошибке. Добавим в нашу функцию test возможность возникновения ошибки:

package main

import (
        "context"
        "errors"
        "log"
        "time"
        "fmt"
        "math/rand"

        "go.opentelemetry.io/otel"
        "go.opentelemetry.io/otel/codes"
        "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.24.0"
        "go.opentelemetry.io/otel/trace"
        "google.golang.org/grpc"
        "google.golang.org/grpc/credentials/insecure"
)

const (
        ServiceName = "service"
)

var (
        prv *sdktrace.TracerProvider
        tr trace.Tracer
        errSpan = errors.New("span error")
)

func initTracer(ctx context.Context) error{
        conn, err := grpc.NewClient("jaeger:4317",
                grpc.WithTransportCredentials(insecure.NewCredentials()),
        )
        if err != nil {
                return err
        }

        exp, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn))
        if err != nil {
                return err
        }

        res, err := resource.New(ctx,
                resource.WithAttributes(
                        semconv.ServiceName(ServiceName),
                ),
        )
        if err != nil {
                return err
        }

        prv = sdktrace.NewTracerProvider(
                sdktrace.WithBatcher(exp),
                sdktrace.WithResource(res),
        )

        otel.SetTracerProvider(prv)
        tr = prv.Tracer("tracer")

        return nil
}

func main() {
        ctx := context.Background()
        if err := initTracer(ctx);err != nil {
                log.Fatal("init tracer", err)
        }

        ctx, span := tr.Start(ctx, "root span")
        test(ctx, 1)
        test(ctx, 2)
        span.End()

        if err := prv.Shutdown(ctx); err != nil {
                log.Fatal("failed shutdown", err)
        }
}

func test(ctx context.Context, count int) {
        ctx, span := tr.Start(ctx, fmt.Sprintf("span-%d", count))
        defer span.End()
        num := rand.Intn(5)+1
        time.Sleep(time.Duration(num)*time.Second)
        if num%2 == 0 {
                span.SetStatus(codes.Error, errSpan.Error())
                span.RecordError(errSpan)
        }
}

И запустим:

$ docker compose up -d --build
[+] Running 2/2
 ✔ Container test    Started                                                          0.6s
 ✔ Container jaeger  Running                                                          0.0s

После чего может появиться трейс и спан с ошибкой:

А в деталях спана можно увидеть информацию об ошибке:

Nested Spans

Трассировку можно также производить по стеку вызовов, в golang это делается передачей информации о спане через контекст. Добавим в нашу функцию test возможность рекурсивного запуска с передачей контекста.

package main

import (
        "context"
        "errors"
        "fmt"
        "log"
        "math/rand"
        "time"

        "go.opentelemetry.io/otel"
        "go.opentelemetry.io/otel/codes"
        "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.24.0"
        "go.opentelemetry.io/otel/trace"
        "google.golang.org/grpc"
        "google.golang.org/grpc/credentials/insecure"
)

const (
        ServiceName = "service"
)

var (
        prv *sdktrace.TracerProvider
        tr trace.Tracer
        errSpan = errors.New("span error")
)

func initTracer(ctx context.Context) error{
        conn, err := grpc.NewClient("jaeger:4317",
                grpc.WithTransportCredentials(insecure.NewCredentials()),
        )
        if err != nil {
                return err
        }

        exp, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn))
        if err != nil {
                return err
        }

        res, err := resource.New(ctx,
                resource.WithAttributes(
                        semconv.ServiceName(ServiceName),
                ),
        )
        if err != nil {
                return err
        }

        prv = sdktrace.NewTracerProvider(
                sdktrace.WithBatcher(exp),
                sdktrace.WithResource(res),
        )

        otel.SetTracerProvider(prv)
        tr = prv.Tracer("tracer")

        return nil
}

func main() {
        ctx := context.Background()
        if err := initTracer(ctx);err != nil {
                log.Fatal("init tracer", err)
        }

        ctx, span := tr.Start(ctx, "root span")

        test(ctx, 1)

        test(ctx, 3)

        test(ctx, 1)

        span.End()

        if err := prv.Shutdown(ctx); err != nil {
                log.Fatal("failed shutdown", err)
        }
}

func test(ctx context.Context, count int) {
        if count < 1 {
                return
        }
        ctx, span := tr.Start(ctx, fmt.Sprintf("span-%d", count))
        defer span.End()
        test(ctx, count - 1)
        num := rand.Intn(5)+1
        time.Sleep(time.Duration(num)*time.Second)
        if num%2 == 0 {
                span.SetStatus(codes.Error, errSpan.Error())
                span.RecordError(errSpan)
        }
        log.Println("called test", count, num)
}

И запустим:

$ docker compose up -d --build
[+] Running 2/2
 ✔ Container test    Started                                                          0.5s
 ✔ Container jaeger  Running                                                          0.0s

После чего можем наблюдать в трейсе вложенность спанов:

Черной полосой в трейсе указывается critical path, который показывает в каких местах при обработке вносится задержка. Также в информации об ошибке можно увидеть на какой секунде она произошла при обработке данного запроса.

Таким образом jaeger позволяет анализировать работу приложения, находя проблемные и узкие места.