OpenTelemetry стек в Go: Metrics, Tracing, Logs
- четверг, 12 февраля 2026 г. в 00:00:05
Всем привет!
В этой статье я решил разобрать стек 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
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
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
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"
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
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']
Создаем провайдеров для метрик, трейсов и логов:
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) } }
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) }
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")) }
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 }
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 }



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




Логгер и спаны используют один контекст и таким образом в трейсах можно видеть связанные с трейсом логи. Лог хэндер достает 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).
В примере измеряется событие (запрос/ошибка), а не текущее состояние. Gauge нужен для «состояния в момент времени» (например, активные соединения, память). В моем примере я смотрю latency запросов.
Histogram агрегируется через Prometheus и подходит для p95/p99 across instances. Summary считается только локально на экземпляре и плохо агрегируется.
UUID в label создаёт высокую cardinality → разрушает хранение и графики в Prometheus. Лучше использовать UUID только в trace/log, не в label метрик.
Код написан плохо, признаю, но моя задача была сделать минимальный шаблон для применения стека OpenTelemtry в Go для людей незнакомых с ним и для себя, поэтому сильно не ругайтесь)