golang

OpenTelemetry стек в Go: Metrics, Tracing, Logs

  • четверг, 12 февраля 2026 г. в 00:00:05
https://habr.com/ru/articles/994626/

Всем привет!

В этой статье я решил разобрать стек OpenTelemetry (OTel) для Go приложений:

Tracing → Tempo

Metrics → Prometheus

Logs → Loki

Будет минимум теории — пройду чисто по шагам: что сделать, для чего и как увидеть результат.

Запуск контейнеров

Мы будем запускать:

app-1 — клиент

app-2 — сервер

otel-collector — точка входа для всех источников телеметрии

prometheus — метрики

tempo — трейсы

loki — логгирование

grafana — UI для всего выше

Вот docker-compose.yml для всего стека:

services:
  # client
  app-1:
    build: ./app_1
    container_name: app-1
    depends_on:
      otel-collector:
        condition: service_started
    networks:
      - observability

  # server
  app-2:
    build: ./app_2
    container_name: app-2
    ports:
      - "8080:8080"
    depends_on:
      otel-collector:
        condition: service_started
    networks:
      - observability

  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    container_name: otel-collector
    ports:
      - "4318:4318"
      - "4317:4317"
      - "8889:8889"
    command: ["--config=/etc/otelcol/config.yaml"]
    volumes:
      - ./configs/otel-collector-config.yaml:/etc/otelcol/config.yaml:ro
    depends_on:
      tempo:
        condition: service_healthy
      prometheus:
        condition: service_healthy
    networks:
      - observability

  prometheus:
    image: prom/prometheus:latest
    container_name: prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./configs/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./configs/prometheus-rules/:/etc/prometheus/rules/:ro
      - prometheus-data:/prometheus
    command:
      - "--config.file=/etc/prometheus/prometheus.yml"
    networks:
      - observability
    healthcheck:
      test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9090/-/healthy" ]
      interval: 5s
      timeout: 5s
      retries: 5

  tempo:
    image: grafana/tempo:2.7.0
    container_name: tempo
    restart: unless-stopped
    ports:
      - "3200:3200"       # Tempo HTTP API
    volumes:
      - ./configs/tempo-config.yaml:/etc/tempo/tempo.yaml
      - tempo-data:/var/tempo
    command: [ "-config.file=/etc/tempo/tempo.yaml" ]
    networks:
      - observability
    healthcheck:
      test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3200/ready" ]
      interval: 5s
      timeout: 5s
      retries: 5

  loki:
    image: grafana/loki:3.3.2
    container_name: loki
    ports:
      - "3100:3100"
    volumes:
      - ./configs/loki-config.yaml:/etc/loki/local-config.yaml:ro
    command: -config.expand-env=true -config.file=/etc/loki/local-config.yaml
    networks:
      - observability

  grafana:
    image: grafana/grafana:latest
    container_name: grafana
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=admin
      - GF_PATHS_PROVISIONING=/etc/grafana/provisioning
      - GF_PATHS_DASHBOARDS=/var/lib/grafana/dashboards
    volumes:
      - ./configs/grafana/datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml:ro
    depends_on:
      - prometheus
      - tempo
    networks:
      - observability

volumes:
  prometheus-data:
  tempo-data:

networks:
  observability:
    driver: bridge
    ipam:
      config:
        - subnet: 10.10.0.0/24

Конфиги

Tempo

server:
  http_listen_port: 3200 
  grpc_listen_port: 9095

distributor:
  receivers:
    otlp:
      protocols:
      # Именно сюда коллектор будет слать трейсы
        grpc:
          endpoint: "0.0.0.0:4317"
        http:
          endpoint: "0.0.0.0:4318"

ingester:
  # если к трейсу не будут прикреплены спаны в течение trace_idle_period
  # то он будет стёрт; чем ниже значение тем быстрее будут видны на ui
  trace_idle_period: 5s
  # сколько времени будет жить блок до записи на диск
  # чем ниже значение тем чаще будет запись на диск и больше блоков
  max_block_duration: 30m

querier:
  frontend_worker:
    # нужно для того чтобы Grafana могла показывать трейсы
    frontend_address: tempo:9095

query_frontend:
  search:
    duration_slo: 5s
  trace_by_id:
    duration_slo: 5s

# где хранятся трейсы
storage:
  trace:
    backend: local
    local:
      path: /var/tempo/traces

# сжимает блоки, удаляет старые блоки => умеренное использование диска,
# но трейсы исчезают через block_retention времени
compactor:
  compaction:
    block_retention: 30m

Prometheus

global:
  # как часто собирать метрики
  scrape_interval: 60s
  # как часто определять правила
  evaluation_interval: 60s

scrape_configs:
  # все метрики будут иметь такой лейбл
  - job_name: "otel-collector"
  # куда otel будет экспортировать метрики
    static_configs:
      - targets: ["otel-collector:8889"]

rule_files:
  # доп. правила, например алерты
  - /etc/prometheus/rules/*.yaml

Prometheus rules (опционально, пример)

groups:
  - name: app1-service-alerts
    rules:
      - alert: App1LatencyHigh
        expr: histogram_quantile(
          0.95,
          sum by (le) (rate(app_1_requests_latency_ms_bucket[5m]))) > 2000
        for: 3m
        labels:
          severity: warning
        annotations:
          summary: "95th percentile latency above 2000ms for app_1"

  - name: app2-service-alerts
    rules:
      - alert: App2HighErrorRate
        expr: rate(app_2_requests_total{status="failed"}[5m]) / rate(app_2_requests_total[5m]) > 0.05
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "High error rate detected for app_2"

Loki

auth_enabled: false

server:
  http_listen_port: 3100
  grpc_listen_port: 9097

common:
  instance_addr: 127.0.0.1
   # где хранятся логи
  path_prefix: /tmp/loki
  storage:
    filesystem:
      chunks_directory: /tmp/loki/chunks
      rules_directory: /tmp/loki/rules
  # лог хранится только в одном экземпляре (нет реплик)
  replication_factor: 1
  ring:
    kvstore:
      # может потерять данные при рестарте
      store: inmemory

limits_config:
  allow_structured_metadata: true

schema_config:
  # как хранить и индексировать логи
  configs:
    - from: 2020-10-24
      store: tsdb
      object_store: filesystem
      schema: v13
      index:
        prefix: index_
        period: 24h

Grafana datasources (опционально, можно и через UI)

apiVersion: 1

datasources:
  # PROMETHEUS
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true
    jsonData:
      httpMethod: POST
      # Связка с Tempo для перехода Metrics -> Traces
      exemplarTraceIdDestinations:
        - name: trace_id
          datasourceUid: tempo

  # LOKI
  - name: Loki
    type: loki
    access: proxy
    url: http://loki:3100
    jsonData:
      # Связка с Tempo для перехода Logs -> Traces
      derivedFields:
        - datasourceUid: tempo
          matcherRegex: "traceID=(\\w+)"
          name: TraceID
          url: "$${__value.raw}"

  # TEMPO
  - name: Tempo
    uid: tempo
    type: tempo
    access: proxy
    url: http://tempo:3200
    jsonData:
      httpMethod: GET
      grpcAddress: "tempo:9095"
      insecure: true
      nodeGraph:
        enabled: true
      serviceMap:
        datasourceUid: 'Prometheus'
      lokiSearch:
        datasourceUid: 'Loki'
      tracesToLogsV2:
        datasourceUid: 'Loki'
        groupSigularBy: ['cluster', 'namespace', 'pod']

Код клиента (app-1)

Инициализация провайдеров

Создаем провайдеров для метрик, трейсов и логов:

func InitOTel(ctx context.Context, serviceName string) (*log.LoggerProvider, func(context.Context) error) {
    ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
    defer cancel()

    res := resource.NewWithAttributes(
       semconv.SchemaURL,
       semconv.ServiceName(serviceName),
       semconv.ServiceVersion("1.0.0"),
    )

    // ---- Tracing ----
    traceExp, err := otlptracegrpc.New(ctx,
       otlptracegrpc.WithEndpoint("otel-collector:4317"),
       otlptracegrpc.WithInsecure(),
    )
    if err != nil {
       panic(err)
    }

    tracerProvider := sdktrace.NewTracerProvider(
       sdktrace.WithSampler(
          sdktrace.ParentBased(
             sdktrace.TraceIDRatioBased(0.1), // 10%
          ),
       ),
       sdktrace.WithBatcher(traceExp),
       sdktrace.WithResource(res),
    )

    otel.SetTracerProvider(tracerProvider)
    otel.SetTextMapPropagator(propagation.TraceContext{})

    // ---- Metrics ----
    metricExp, err := otlpmetricgrpc.New(ctx,
       otlpmetricgrpc.WithEndpoint("otel-collector:4317"),
       otlpmetricgrpc.WithInsecure(),
    )
    if err != nil {
       panic(err)
    }

    meterProvider := sdkmetric.NewMeterProvider(
       sdkmetric.WithReader(
          sdkmetric.NewPeriodicReader(metricExp, sdkmetric.WithInterval(10*time.Second)),
       ),
       sdkmetric.WithResource(res),
    )

    otel.SetMeterProvider(meterProvider)

    // ---- Logger ----
    logExp, _ := otlploggrpc.New(ctx,
       otlploggrpc.WithEndpoint("otel-collector:4317"),
       otlploggrpc.WithInsecure(),
    )

    logProvider := log.NewLoggerProvider(
       log.WithProcessor(log.NewBatchProcessor(logExp)),
       log.WithResource(res),
    )

    return logProvider, func(ctx context.Context) error {
       if err = tracerProvider.Shutdown(ctx); err != nil {
          return err
       }

       if err = meterProvider.Shutdown(ctx); err != nil {
          return err
       }

       if err = logProvider.Shutdown(context.TODO()); err != nil {
          return err
       }

       return nil
    }
}

Основной код клиента

package main

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

    "go.opentelemetry.io/contrib/bridges/otelslog"
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/codes"
    "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
    "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    "go.opentelemetry.io/otel/metric"
    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/sdk/log"
    sdkmetric "go.opentelemetry.io/otel/sdk/metric"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)

func main() {
    logProvider, shutdown := InitOTel(context.TODO(), "app_1")
    defer shutdown(context.TODO())

    l := slog.New(otelslog.NewHandler(
       "app_1",
       otelslog.WithLoggerProvider(logProvider),
    ))
    t := otel.Tracer("client")
    m := otel.Meter("client")
  
    counter, _ := m.Int64Counter("app_1_requests_total")
    latency, _ := m.Float64Histogram(
       "app_1_requests_latency_ms",
       metric.WithUnit("ms"),
       metric.WithDescription("Request latency in ms"),
    )

    cl := &http.Client{
       Transport: otelhttp.NewTransport(http.DefaultTransport),
    }

    // генерим трафик для сервера
    for {
       apiCall := func() {
          ctx, span := t.Start(context.Background(), "client_request")
          defer span.End()

          l.Info("processing request", slog.Any("destination", "app-2"))

          start := time.Now()
          defer latency.Record(
             ctx,
             time.Since(start).Seconds(),
             metric.WithAttributes(
                attribute.String("api", "app_2"),
                attribute.String("endpoint", "/health"),
             ),
          )

          req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://app-2:8080/health", nil)
          res, err := cl.Do(req)
          if err != nil {
             span.RecordError(err)
             span.SetStatus(codes.Error, err.Error())
             return
          }
          defer res.Body.Close()

          l.Info("processed request", slog.Any("destination", "app-2"), slog.Any("status", res.StatusCode))

          switch res.StatusCode {
          case http.StatusOK:
             counter.Add(
                ctx,
                1,
                metric.WithAttributes(
                   attribute.String("api", "app_2"),
                   attribute.String("endpoint", "/health"),
                   attribute.String("status", "success"),
                ),
             )
          default:
             counter.Add(
                ctx,
                1,
                metric.WithAttributes(
                   attribute.String("api", "app_2"),
                   attribute.String("endpoint", "/health"),
                   attribute.String("status", "failed"),
                ),
             )
          }
       }

       apiCall()
       time.Sleep(time.Second * 5)
    }
}

Код сервера (app-2)

Инициализация провайдеров

func InitOTel(ctx context.Context, serviceName string) (*log.LoggerProvider, func(context.Context) error) {
    ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
    defer cancel()

    res := resource.NewWithAttributes(
       semconv.SchemaURL,
       semconv.ServiceName(serviceName),
       semconv.ServiceVersion("1.0.0"),
    )

    // ---- Tracing ----
    traceExp, err := otlptracegrpc.New(ctx,
       otlptracegrpc.WithEndpoint("otel-collector:4317"),
       otlptracegrpc.WithInsecure(),
    )
    if err != nil {
       panic(err)
    }

    tracerProvider := sdktrace.NewTracerProvider(
       sdktrace.WithSampler(
          sdktrace.ParentBased(
             sdktrace.TraceIDRatioBased(0.1),
          ),
       ),
       sdktrace.WithBatcher(traceExp),
       sdktrace.WithResource(res),
    )

    otel.SetTracerProvider(tracerProvider)
    otel.SetTextMapPropagator(propagation.TraceContext{})

    // ---- Metrics ----
    metricExp, err := otlpmetricgrpc.New(ctx,
       otlpmetricgrpc.WithEndpoint("otel-collector:4317"),
       otlpmetricgrpc.WithInsecure(),
    )
    if err != nil {
       panic(err)
    }

    meterProvider := sdkmetric.NewMeterProvider(
       sdkmetric.WithReader(
          sdkmetric.NewPeriodicReader(metricExp, sdkmetric.WithInterval(10*time.Second)),
       ),
       sdkmetric.WithResource(res),
    )

    otel.SetMeterProvider(meterProvider)

    // ---- Logger ----
    logExp, _ := otlploggrpc.New(ctx,
       otlploggrpc.WithEndpoint("otel-collector:4317"),
       otlploggrpc.WithInsecure(),
    )

    logProvider := log.NewLoggerProvider(
       log.WithProcessor(log.NewBatchProcessor(logExp)),
       log.WithResource(res),
    )
  
    return logProvider, func(ctx context.Context) error {
       if err = tracerProvider.Shutdown(ctx); err != nil {
          return err
       }

       if err = meterProvider.Shutdown(ctx); err != nil {
          return err
       }

       if err = logProvider.Shutdown(context.TODO()); err != nil {
          return err
       }

       return nil
    }
}

Основной код сервера

package main

import (
    "context"
    "errors"
    "log/slog"
    "math/rand"
    "net/http"
    "time"

    "github.com/google/uuid"
    "go.opentelemetry.io/contrib/bridges/otelslog"
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/codes"
    "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
    "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    "go.opentelemetry.io/otel/metric"
    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/sdk/log"
    sdkmetric "go.opentelemetry.io/otel/sdk/metric"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
    "go.opentelemetry.io/otel/trace"
)

func main() {
    logProvider, shutdown := InitOTel(context.TODO(), "app_2")
    defer shutdown(context.TODO())

    l := slog.New(otelslog.NewHandler(
       "app_2",
       otelslog.WithLoggerProvider(logProvider),
    ))
    t := otel.Tracer("server")
    m := otel.Meter("server")

    r := NewRepository(t)
    s := NewService(t, r)
    h := NewHandler(t, m, s, l)

    mux := http.NewServeMux()
    mux.HandleFunc("GET /health", h.healthHandler)

    l.Info("Starting server", slog.Any("port", 8080))

    handler := otelhttp.NewHandler(mux, "app_2")
    http.ListenAndServe(":8080", handler)
}

Слой адаптера (Server)

func NewHandler(t trace.Tracer, m metric.Meter, s *service, l *slog.Logger) *handler {
    counter, _ := m.Int64Counter("app_2_requests_total")
    latency, _ := m.Float64Histogram(
       "app_2_requests_latency_ms",
       metric.WithUnit("ms"),
       metric.WithDescription("Request latency in ms"),
    )

    return &handler{
       t: t,
       m: m,
       metrics: struct {
          counter metric.Int64Counter
          latency metric.Float64Histogram
       }{counter: counter, latency: latency},
       srvc: s,
       l:    l,
    }
}

type handler struct {
    t trace.Tracer

    m       metric.Meter
    metrics struct {
       counter metric.Int64Counter
       latency metric.Float64Histogram
    }

    srvc *service
    l    *slog.Logger
}

func (h handler) healthHandler(w http.ResponseWriter, r *http.Request) {
    ctx := context.WithValue(r.Context(), "ctx_logger", h.l)

    // начинаю цепочку спанов в текущем сервисе,
    // важно учесть, что контекст берется из запроса чтобы она не прервалась
    ctx, span := h.t.Start(ctx, "health_check_handler")
    defer span.End()

    span.SetAttributes(attribute.String("id", uuid.NewString()))
    span.SetAttributes(attribute.String("stage", "0"))

    h.l.Info("received health check request")
    time.Sleep(time.Millisecond * time.Duration(10*rand.Intn(50)))

    // метрика для отслеживания времени ответа 
    start := time.Now()
    defer h.metrics.latency.Record(
       ctx,
       float64(time.Since(start).Milliseconds()),
       metric.WithAttributes(attribute.String("endpoint", "/health")),
    )

    if err := h.srvc.healthService(ctx); err != nil {
      // счетчик ошибок
       h.metrics.counter.Add(
          ctx, 1,
          metric.WithAttributes(
             attribute.String("endpoint", "/health"),
             attribute.String("status", "failed"),
          ),
       )

      // помечаем спан ошибкой
       span.SetStatus(codes.Error, err.Error())

      // пишем в логи ошибку
       h.l.Error("health check failed", slog.Any("error", err.Error()))
       
       w.WriteHeader(http.StatusServiceUnavailable)
       w.Write([]byte("some error happened"))
       
       return
    }

  // счетчик успешных ответов
    h.metrics.counter.Add(
       ctx, 1,
       metric.WithAttributes(
          attribute.String("endpoint", "/health"),
          attribute.String("status", "success"),
       ),
    )

  // помечаем спан успешным
    span.SetStatus(codes.Ok, "success")

  // пишем лог
    h.l.Info("health check succeeded")
  
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("ok"))
}

Слой бизнес-логики (Service)

func NewService(t trace.Tracer, r *repository) *service {
    return &service{
       t:    t,
       repo: r,
    }
}

type service struct {
    t trace.Tracer
    repo *repository
}

func (s service) healthService(ctx context.Context) error {
    l := ctx.Value("ctx_logger").(*slog.Logger)

    // продолжаю цепочку спанов
    ctx, span := s.t.Start(ctx, "health_check_service")
    defer span.End()

    span.SetAttributes(attribute.String("stage", "1"))

  
    time.Sleep(time.Millisecond * time.Duration(10*rand.Intn(5)))
    l.Info("doing some business logic")

    if err := s.repo.healthRepo(ctx); err != nil {
      // помечаю спан как ошибочный и возвращаю ошибку
      span.SetStatus(codes.Error, err.Error())
      return err
    }

    return nil
}

Слой работы с базой данных (Repository)

func NewRepository(t trace.Tracer) *repository {
    return &repository{t: t}
}

type repository struct {
    t trace.Tracer
}

func (r repository) healthRepo(ctx context.Context) error {
    l := ctx.Value("ctx_logger").(*slog.Logger)

    // продолжаю цепочку спанов
    ctx, span := r.t.Start(ctx, "health_check_repo")
    defer span.End()

    span.SetAttributes(attribute.String("stage", "2"))
  
    l.Info("doing some db logic")
    time.Sleep(time.Millisecond * time.Duration(10*rand.Intn(10)))

    // В среднем 1 из 3 запросов будут с ошибкой
    if rand.Intn(3) == 0 {
       err := errors.New("error 🐹")
        // помечаю спан как ошибочный и возвращаю ошибку
       span.RecordError(err)
       span.SetStatus(codes.Error, err.Error())
       return err
    }

    return nil
}

Теперь запускаем это все и идем в Grafana

Метрики (Prometheus)

Counter succeeded/failed запросов

Time series (зеленый - успешные, желтый - с ошибкой)
Time series (зеленый - успешные, желтый - с ошибкой)
Запросы к графику
Запросы к графику

Histogram p95 для времени запросов

p95(95%) запросов укладывается в это время
p95(95%) запросов укладывается в это время

Трейсы (Tempo)

У каждого трейса можно увидеть логи, которые к нему относятся, нажав на иконку Log.

Трейс и его логи
Трейс и его логи
Успешный трейс от клиента к серверу
Успешный трейс от клиента к серверу
Трейс с ошибкой от клиента к серверу
Трейс с ошибкой от клиента к серверу
Таблица всех трейсов из клиента
Таблица всех трейсов из клиента

Логи (Loki)

Логгер и спаны используют один контекст и таким образом в трейсах можно видеть связанные с трейсом логи. Лог хэндер достает trace_id и span_id и добавляет их в лог.

Все логи сервера
Все логи сервера

Заключение

В этой статье я представил вам (и самому себе на будущее) пример применения стека OpenTelemtry в Go приложениях, было сделано: запуск контейнеров, создание приложений и покрытие их логами/трейсами/метриками, обзор результата в Grafana. Все было сделано максимально сжато и примитивно, но это было намеренно, т.к это лишь шаблон, на его основе можно будет строить дальнейшие сборы данных и их мониторинг.

Краткие заметки

  • Порядок событий: app(traces/metrics/logs exporter) -> otel-collector -> prometheus/loki/tempo etc. (хранят данные) -> grafana (визуализация данных).

  • Трейсы должны наследовать один и тот же контекст чтобы не рвалась цепочка. (также, должен быть настроен propagator (TraceContext))

  • Памятка по метрикам:

    • Counter (счетчик, не может уменьшаться и сбрасывается только при рестарте процесса: запросы, ошибки, джобы и тп.) Пример вопроса чтобы определить: "Сколько?"

    • Gauge (значение может расти и падать, отображение текущего состояния: использование памяти, кол-во подключений и тп.) Пример вопроса чтобы определить: "Сколько в определенный момент времени?"

    • Histogram (агрегируется между подами, распределение по бакетам: время ответа, объем трафика, квантили и тп.) Пример вопроса чтобы определить: "Как распределены значения? За сколько выполняется n%?"

    • Summary (похоже на Histogram, но работает с конкретным инстансом) Пример вопроса чтобы определить: "Как распределены значения на конкретном инстансе? За сколько выполняется n% на конкретном инстансе?"

  • Для логов достаточно использовать подходящую библиотеку от otel с текущим логгером.

  • В приложении создаются OTel SDK провайдеры, которые отправляют данные в otel-collector. Collector принимает данные (receivers), обрабатывает их (processors) и экспортирует в целевые системы (exporters).

Что здесь сделано упрощённо и почему

Почему Histogram, а не Gauge

В примере измеряется событие (запрос/ошибка), а не текущее состояние. Gauge нужен для «состояния в момент времени» (например, активные соединения, память). В моем примере я смотрю latency запросов.

Почему Histogram, а не Summary

Histogram агрегируется через Prometheus и подходит для p95/p99 across instances. Summary считается только локально на экземпляре и плохо агрегируется.

Почему нельзя UUID в метрики

UUID в label создаёт высокую cardinality → разрушает хранение и графики в Prometheus. Лучше использовать UUID только в trace/log, не в label метрик.

P.S.

Код написан плохо, признаю, но моя задача была сделать минимальный шаблон для применения стека OpenTelemtry в Go для людей незнакомых с ним и для себя, поэтому сильно не ругайтесь)