Опирается на правила: R-OBS-CFG-1R-OBS-CFG-4 и R-OBS-CFG-X1R-OBS-CFG-X3 из Observability Style Guide → раздел 5. Конфигурация.

Важно знать

  • Два HTTP-сервера в одном процессе: бизнес-трафик на основном порту, management (/metrics, /health/*, /info) — на отдельном.
  • APP_ENV управляет форматом логов: productionslog.NewJSONHandler, иное → slog.NewTextHandler.
  • Explicit маршруты на management-сервере: только /metrics, /health/live, /health/ready, /info. pprof без auth в проде — запрещён.
  • SLO buckets для гистограммы латентности задаются один раз — при регистрации HistogramVec; chi route pattern вместо raw URL в label — иначе каждый /orders/123 создаёт отдельную time series.
  • slog.LevelVar позволяет менять уровень логов через management endpoint без перезапуска сервиса.
  • Стандартные labels service/env/version — через prometheus.Labels при регистрации, не в каждом .WithLabelValues(...).
  • net/http/pprof не монтируется на production management-порт без mTLS или сетевой политики — профили памяти раскрывают внутренние данные.

Конфигурация observability в Go собирается из трёх частей: инициализация логгера (internal/platform/log/), настройка Prometheus-метрик (internal/platform/metrics/), запуск двух серверов (cmd/server/main.go). Эта статья — про все три.

Отдельный management-порт

R-OBS-CFG-1 — два http.Server в одном процессе, запущенных через errgroup.

// cmd/server/main.go
import (
    "golang.org/x/sync/errgroup"
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

func run(ctx context.Context, cfg Config) error {
    router := buildRouter(deps)
    businessSrv := &http.Server{
        Addr:    cfg.Addr,            // :8080
        Handler: otelhttp.NewHandler(router, cfg.ServiceName),
    }
    managementSrv := platform.StartManagement(cfg.ManagementAddr, deps) // :8081

    g, gCtx := errgroup.WithContext(ctx)
    g.Go(func() error { return businessSrv.ListenAndServe() })
    g.Go(func() error { return managementSrv.ListenAndServe() })
    g.Go(func() error {
        <-gCtx.Done()
        shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
        defer cancel()
        _ = businessSrv.Shutdown(shutCtx)
        _ = managementSrv.Shutdown(shutCtx)
        return nil
    })
    return g.Wait()
}

Management-сервер монтирует ровно четыре маршрута:

// internal/platform/metrics/server.go
func StartManagement(addr string, deps Deps) *http.Server {
    mux := http.NewServeMux()
    mux.Handle("GET /metrics", promhttp.Handler())
    mux.HandleFunc("GET /health/live", liveHandler)
    mux.Handle("GET /health/ready", deps.ReadyHandler)
    mux.HandleFunc("GET /info", infoHandler(deps.Version, deps.Commit, deps.BuildTime))
    mux.HandleFunc("PUT /log-level", logLevelHandler(deps.LogLevel))
    return &http.Server{Addr: addr, Handler: mux}
}

Что это даёт:

  • Network policy в K8s разрешает Prometheus scraper подключаться на 8081, Ingress публикует только 8080.
  • Scraping-трафик (каждые 15 секунд) и K8s probes (каждые 5 секунд) не занимают goroutine-pool бизнес-сервера.
  • pprof и debug-эндпоинты остаются за пределами management, не попадают в продакшн без явного решения.

Конфиг логирования по APP_ENV

R-OBS-CFG-4 — выбор handler-а единожды при старте, не глобальный slog.SetDefault.

// internal/platform/log/setup.go
func New(env string, level *slog.LevelVar) *slog.Logger {
    opts := &slog.HandlerOptions{Level: level}
    if env == "production" {
        return slog.New(slog.NewJSONHandler(os.Stdout, opts))
    }
    return slog.New(slog.NewTextHandler(os.Stdout, opts))
}

slog.LevelVar — динамический уровень. Передаётся и в setup, и в management endpoint:

// cmd/server/main.go
var logLevel slog.LevelVar // default: 0 = INFO
log := platform.New(cfg.AppEnv, &logLevel)

Management endpoint для смены уровня в проде без перезапуска:

// internal/platform/metrics/server.go
func logLevelHandler(level *slog.LevelVar) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var body struct{ Level string `json:"level"` }
        if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
            http.Error(w, "bad request", http.StatusBadRequest)
            return
        }
        var l slog.Level
        if err := l.UnmarshalText([]byte(body.Level)); err != nil {
            http.Error(w, "unknown level", http.StatusBadRequest)
            return
        }
        level.Set(l)
        w.WriteHeader(http.StatusNoContent)
    }
}

В локальной разработке TextHandler показывает читаемый вывод:

10:42:03.211 INFO order_confirmed order_id=ORD-9912 customer_id=C-441

В проде JSONHandler пишет структурированный JSON, который Loki/Datadog парсит без regex:

{"time":"2026-06-19T10:42:03.211Z","level":"INFO","msg":"order_confirmed","order_id":"ORD-9912","customer_id":"C-441","trace_id":"4b3e..."}

Histogram buckets и стандартные labels

R-OBS-CFG-3 — SLO buckets задаются при регистрации гистограммы, не на лету.

// internal/platform/metrics/http.go
var (
    httpRequestDurationSeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{
        Name:    "http_request_duration_seconds",
        Help:    "HTTP request latency",
        Buckets: []float64{0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0},
    }, []string{"method", "path", "status_class"})

    httpRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
        Name: "http_requests_total",
        Help: "Total HTTP requests",
    }, []string{"method", "path", "status_class"})
)

Для платёжных эндпоинтов с SLO «p99 < 500ms» bucket 0.5 даёт точный histogram_quantile(0.99, ...) в Prometheus без интерполяции. prometheus.DefBuckets — для сервисов без жёстких SLO.

R-OBS-MTR-2 — стандартные labels service/env/version через prometheus.Labels:

// internal/platform/metrics/common.go
func CommonLabels(cfg Config) prometheus.Labels {
    return prometheus.Labels{
        "service": cfg.ServiceName,
        "env":     cfg.AppEnv,
        "version": cfg.Version,
    }
}

Labels применяются к CounterVec/HistogramVec через .MustCurryWith(commonLabels):

// internal/order/metrics.go
var ordersCreatedTotal = promauto.NewCounterVec(prometheus.CounterOpts{
    Name: "orders_created_total",
    Help: "Orders successfully created",
}, []string{"service", "env", "version", "payment_method"})

type OrderMetrics struct {
    created *prometheus.CounterVec
}

func NewOrderMetrics(cfg platform.Config) *OrderMetrics {
    return &OrderMetrics{
        created: ordersCreatedTotal.MustCurryWith(platform.CommonLabels(cfg)),
    }
}

func (m *OrderMetrics) OrderCreated(paymentMethod string) {
    m.created.WithLabelValues(paymentMethod).Inc()
}

Использование chi route pattern в label path

R-OBS-MTR-7 — path в label должен быть шаблоном маршрута, не raw URL. Иначе /orders/ORD-001 и /orders/ORD-002 — разные time series, Prometheus получает неограниченную cardinality.

// internal/platform/middleware/metrics.go
func Metrics(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
        start := time.Now()
        next.ServeHTTP(ww, r)
        routeCtx := chi.RouteContext(r.Context())
        path := "unknown"
        if routeCtx != nil && routeCtx.RoutePattern() != "" {
            path = routeCtx.RoutePattern()
        }
        status := statusClass(ww.Status())
        httpRequestsTotal.WithLabelValues(r.Method, path, status).Inc()
        httpRequestDurationSeconds.WithLabelValues(r.Method, path, status).
            Observe(time.Since(start).Seconds())
    })
}

func statusClass(code int) string {
    switch {
    case code < 400:
        return "success"
    case code < 500:
        return "client_error"
    default:
        return "server_error"
    }
}

chi.RouteContext(r.Context()).RoutePattern() возвращает /orders/{orderID} — фиксированное число time series независимо от числа заказов.

/info endpoint

R-OBS-CFG-2 — информация о сборке доступна через management endpoint. Значения инжектируются на этапе сборки через -ldflags:

// cmd/server/version.go
var (
    version   = "dev"
    commit    = "none"
    buildTime = "unknown"
)
# Makefile
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildTime=$(BUILD_TIME)"
go build $(LDFLAGS) -o bin/order-service ./cmd/server
func infoHandler(version, commit, buildTime string) http.HandlerFunc {
    payload, _ := json.Marshal(map[string]string{
        "version":    version,
        "commit":     commit,
        "build_time": buildTime,
    })
    return func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        _, _ = w.Write(payload)
    }
}

Что запрещено

АнтипаттернПравилоЧто взамен
net/http/pprof без auth на management-портуR-OBS-CFG-X1mTLS или network policy; не монтировать в проде
Один порт для бизнес и managementR-OBS-CFG-X2два http.Server; :8080 и :8081
Все debug-эндпоинты (expvar, /debug/vars, raw stack dump) без контроляR-OBS-CFG-X3explicit список маршрутов; ничего лишнего
prometheus.DefBuckets для эндпоинтов с жёстким SLO по латентностиR-OBS-CFG-3кастомные buckets с границей на SLO-пороге
slog.SetDefault(...) в пакете, не в mainR-OBS-CFG-4логгер через конструктор, DI в хендлеры
raw URL в label path (/orders/ORD-9912)R-OBS-MTR-X1chi.RouteContext(...).RoutePattern()
user_id / order_id как label-значение метрикиR-OBS-MTR-X1traces (OTel span attributes), не метрики
fmt.Println / fmt.Fprintf(os.Stderr) вместо slogR-OBS-LOG-X2log.InfoContext(ctx, ...) / log.ErrorContext(ctx, ...)

Куда дальше

  • Context propagation — RequestID-middleware, порядок в chi-цепочке, ctx в горутинах.
  • Health checks — liveHandler, readiness с TTL-кешем, pgx ping.
  • Logging — структурные поля, OTel-slog bridge, маскировка PII.
  • Metrics — RED-middleware, бизнес-счётчики, GoCollector.
  • SLO и алерты — recording rules, multi-window burn-rate alerts, runbook.
  • Tracing — OTel setup, otelhttp, otelpgx, defer span.End().