Опирается на правила:
R-OBS-CFG-1…R-OBS-CFG-4иR-OBS-CFG-X1…R-OBS-CFG-X3из Observability Style Guide → раздел 5. Конфигурация.
Важно знать
- Два HTTP-сервера в одном процессе: бизнес-трафик на основном порту, management (
/metrics,/health/*,/info) — на отдельном.APP_ENVуправляет форматом логов:production→slog.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-X1 | mTLS или network policy; не монтировать в проде |
| Один порт для бизнес и management | R-OBS-CFG-X2 | два http.Server; :8080 и :8081 |
Все debug-эндпоинты (expvar, /debug/vars, raw stack dump) без контроля | R-OBS-CFG-X3 | explicit список маршрутов; ничего лишнего |
prometheus.DefBuckets для эндпоинтов с жёстким SLO по латентности | R-OBS-CFG-3 | кастомные buckets с границей на SLO-пороге |
slog.SetDefault(...) в пакете, не в main | R-OBS-CFG-4 | логгер через конструктор, DI в хендлеры |
raw URL в label path (/orders/ORD-9912) | R-OBS-MTR-X1 | chi.RouteContext(...).RoutePattern() |
user_id / order_id как label-значение метрики | R-OBS-MTR-X1 | traces (OTel span attributes), не метрики |
fmt.Println / fmt.Fprintf(os.Stderr) вместо slog | R-OBS-LOG-X2 | log.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().