Опирается на правила: R-RES-OBS-1R-RES-OBS-3 и R-RES-OBS-X1 из Resilience Style Guide → раздел 12. Observability.

Важно знать

  • gobreaker не экспортирует метрики автоматически — счётчики регистрируются вручную через promauto; обновление CB-состояния — в OnStateChange callback.
  • Метрики трёх уровней: circuit_breaker_state{system} (gauge, 0/1/2), retry_attempts_total{system,outcome} (counter), bulkhead_rejected_total{system} (counter).
  • OTel-span открывается на public-методе адаптера, атрибуты external.system и circuit_breaker.state проставляются до вызова breaker.Execute — фиксируется состояние в момент вызова, а не после.
  • span.RecordError + span.SetStatus(codes.Error, ...) — только при реальной ошибке; open-state CB маппится в port-ошибку, которая тоже считается ошибкой span.
  • Структурный лог slog.Warn — только на переходах CB-состояния (OnStateChange), не на каждом вызове. INFO-уровень для переходов слишком тихий — SRE пропустит.
  • Алёрты: circuit_breaker_state{state="1"} > 0 дольше 5 минут; bulkhead_rejected_total rate растёт; retry_attempts_total{outcome="failed_exhausted"} rate растёт.
  • promauto регистрирует метрики при инициализации пакета — переменные должны быть package-level var, не локальные.

Gobreaker и retry-go не дают наблюдаемость из коробки — в отличие от Resilience4j/Micrometer-пары, здесь всё подключается явно. Именно поэтому OnStateChange и три promauto-переменные — не украшение, а единственный способ узнать о деградации Sber до жалобы клиента.

Метрики через promauto

R-RES-OBS-1 — метрики CB/retry/bulkhead регистрируются как package-level переменные через promauto. Prometheus scrape'ит их автоматически.

// adapters/out/metrics.go
package out

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
)

var (
    cbState = promauto.NewGaugeVec(prometheus.GaugeOpts{
        Name: "circuit_breaker_state",
        Help: "Current circuit breaker state: 0=closed, 1=open, 2=half-open",
    }, []string{"system"})

    retryAttemptsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
        Name: "retry_attempts_total",
        Help: "Retry attempts by system and outcome",
    }, []string{"system", "outcome"})

    bulkheadRejectedTotal = promauto.NewCounterVec(prometheus.CounterOpts{
        Name: "bulkhead_rejected_total",
        Help: "Requests rejected by bulkhead semaphore",
    }, []string{"system"})
)

CB-состояние обновляется в OnStateChange:

// adapters/out/sber/sber_adapter.go
func newSberBreaker(name string) *gobreaker.CircuitBreaker {
    return gobreaker.NewCircuitBreaker(gobreaker.Settings{
        Name:        name,
        MaxRequests: 3,
        Timeout:     30 * time.Second,
        ReadyToTrip: func(counts gobreaker.Counts) bool {
            if counts.Requests < 10 {
                return false
            }
            ratio := float64(counts.TotalFailures) / float64(counts.Requests)
            return ratio >= 0.30
        },
        OnStateChange: func(name string, from, to gobreaker.State) {
            cbState.WithLabelValues(name).Set(float64(to))
            slog.Warn("circuit breaker state changed",
                "system", name,
                "prev_state", from.String(),
                "new_state", to.String(),
            )
        },
    })
}

После инициализации в /metrics появляется:

circuit_breaker_state{system="sber"} 0
retry_attempts_total{system="sber",outcome="success"} 1234
retry_attempts_total{system="sber",outcome="retried_success"} 80
retry_attempts_total{system="sber",outcome="failed_exhausted"} 12
bulkhead_rejected_total{system="sber"} 3

Что строится в Grafana:

  • Per-system панель: circuit_breaker_state — timeseries по системам; 1 (open) — красная полоса.
  • Bulkhead rejection rate: rate(bulkhead_rejected_total[5m]) — предвестник исчерпания пула.
  • Retry funnel: retry_attempts_total по outcome — сколько дошло с первой попытки, сколько после retry, сколько упало.

Обновление retryAttemptsTotal — в RetryIf и после retry.Do:

// adapters/out/sber/sber_adapter.go
func (a *SberAdapter) GetPaymentStatus(ctx context.Context, ref PaymentRef) (PaymentStatus, error) {
    attempted := 0
    err := retry.Do(
        func() error {
            attempted++
            status, callErr := a.doGetStatus(ctx, ref)
            if callErr == nil {
                _ = status
            }
            return callErr
        },
        retry.Attempts(3),
        retry.DelayType(retry.BackOffDelay),
        retry.Delay(200*time.Millisecond),
        retry.RetryIf(isRetriableError),
    )
    outcome := "success"
    if err != nil {
        if attempted > 1 {
            outcome = "failed_exhausted"
        } else {
            outcome = "failed_no_retry"
        }
    } else if attempted > 1 {
        outcome = "retried_success"
    }
    retryAttemptsTotal.WithLabelValues("sber", outcome).Inc()
    return PaymentStatus{}, err
}

OTel-spans на adapter-методах

R-RES-OBS-2 — span открывается на public-методе адаптера. Атрибуты external.system и circuit_breaker.state проставляются до вызова breaker.Execute — фиксируют состояние CB в момент начала вызова.

// adapters/out/sber/sber_adapter.go
func (a *SberAdapter) Register(ctx context.Context, order Order) (PaymentRef, error) {
    ctx, span := otel.Tracer("sber-adapter").Start(ctx, "SberAdapter.Register")
    defer span.End()

    span.SetAttributes(
        attribute.String("external.system", "sber"),
        attribute.String("circuit_breaker.state", a.breaker.State().String()),
    )

    if err := a.sem.Acquire(ctx, 1); err != nil {
        bulkheadRejectedTotal.WithLabelValues("sber").Inc()
        span.RecordError(err)
        span.SetStatus(codes.Error, "bulkhead rejected")
        return PaymentRef{}, &PaymentSystemUnavailableError{System: "sber", Cause: err}
    }
    defer a.sem.Release(1)

    raw, err := a.breaker.Execute(func() (any, error) {
        callCtx, cancel := capTimeout(ctx, a.cfg.CallTimeout)
        defer cancel()
        return a.doRegister(callCtx, order)
    })
    if err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
        if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) {
            return PaymentRef{}, &PaymentSystemUnavailableError{System: "sber", Cause: err}
        }
        return PaymentRef{}, fmt.Errorf("sber register: %w", err)
    }
    return raw.(PaymentRef), nil
}

Что даёт в Jaeger / Tempo:

  • Slow trace 4.8s на SberAdapter.Register с circuit_breaker.state=half_open — сразу видно, что CB был на грани открытия.
  • span.RecordError + span.SetStatus(codes.Error, ...) позволяет отфильтровать упавшие вызовы по системам.

Аналогично для CustomerAdapter.GetProfile (read) — атрибут circuit_breaker.state показывает, что замедление произошло в момент half-open после восстановления catalog-сервиса.

Структурный slog на переходах CB

R-RES-OBS-3slog.Warn вызывается только в OnStateChange. При нагрузке через SberAdapter.Register проходят тысячи вызовов в минуту — лог на каждом убьёт storage. Переходов CB — единицы в сутки при штатной работе.

OnStateChange: func(name string, from, to gobreaker.State) {
    cbState.WithLabelValues(name).Set(float64(to))
    slog.Warn("circuit breaker state changed",
        "system", name,
        "prev_state", from.String(),
        "new_state", to.String(),
    )
},

Три перехода, которые важны:

ПереходЗначениеДействие
closed → openfailure rate превысил порогSRE смотрит причину; алёрт, если не закрылся через 5 мин
open → half-openистёк Timeout (30s), пробная фаза3 пробных вызова решат судьбу
half-open → closedпробные прошлисистема восстановилась
half-open → openпробные упалидеградация продолжается

Для ProductAdapter (catalog, некритичная система) допустимо slog.Info на half-open → closed — восстановление менее срочно. Для SberAdapter (платежи) — все переходы WARN.

Аналогично подписаться на отклонения bulkhead:

// вызывается в месте отклонения семафором
func recordBulkheadRejection(system string, cause error) {
    bulkheadRejectedTotal.WithLabelValues(system).Inc()
    slog.Warn("bulkhead rejected",
        "system", system,
        "error", cause.Error(),
    )
}

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

АнтипаттернПравилоЧто взамен
Убрать promauto-переменные или не вызывать cbState.Set в OnStateChangeR-RES-OBS-X1promauto package-level var; обновление в callback OnStateChange
slog.Info или slog.Debug на переходе closed → open для платёжных адаптеровR-RES-OBS-3slog.Warn — достаточно заметно, не ERROR
slog.Warn на каждом успешном вызове SberAdapter.RegisterR-RES-OBS-3Только OnStateChange; per-call метрики — через counter
span.SetAttributes после breaker.Executecircuit_breaker.state показывает состояние после, не доR-RES-OBS-2Атрибуты до вызова breaker.Execute
Span без span.RecordError + span.SetStatus(codes.Error, ...) при ошибкеR-RES-OBS-2RecordError + SetStatus при любом err != nil
Метрики без алёртов в Prometheus/AlertmanagerR-RES-OBS-1Алёрт на circuit_breaker_state{state="1"} > 0 дольше 5 мин

Куда дальше

  • Асинхронность и polling — context.WithTimeout вокруг goroutine-вызовов
  • Bulkhead — semaphore.NewWeighted, bulkhead_rejected_total
  • Circuit Breaker — gobreaker, ReadyToTrip, state transitions
  • Конфигурация — envconfig-теги, per-system префиксы, gobreaker.Settings.Name
  • Fallback — errors.As fallback, кеш, частичный ответ
  • Health checks — TTL-кеш, GET /health, readiness
  • Связка с OpenAPI-генератором — oapi-codegen, mapper generated → domain
  • Per-system isolation — один *http.Client per system
  • Retry — retry.Do, BackOffDelay, RetryIf
  • Timeouts — capTimeout, Transport.ResponseHeaderTimeout
  • Где какая защита — outbound vs inbound, task-queue