Опирается на правила:
R-RES-OBS-1…R-RES-OBS-3иR-RES-OBS-X1из Resilience Style Guide → раздел 12. Observability.
Важно знать
gobreakerне экспортирует метрики автоматически — счётчики регистрируются вручную черезpromauto; обновление CB-состояния — вOnStateChangecallback.- Метрики трёх уровней:
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_totalrate растёт;retry_attempts_total{outcome="failed_exhausted"}rate растёт.promautoрегистрирует метрики при инициализации пакета — переменные должны быть package-levelvar, не локальные.
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-3 — slog.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 → open | failure 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 в OnStateChange | R-RES-OBS-X1 | promauto package-level var; обновление в callback OnStateChange |
slog.Info или slog.Debug на переходе closed → open для платёжных адаптеров | R-RES-OBS-3 | slog.Warn — достаточно заметно, не ERROR |
slog.Warn на каждом успешном вызове SberAdapter.Register | R-RES-OBS-3 | Только OnStateChange; per-call метрики — через counter |
span.SetAttributes после breaker.Execute — circuit_breaker.state показывает состояние после, не до | R-RES-OBS-2 | Атрибуты до вызова breaker.Execute |
Span без span.RecordError + span.SetStatus(codes.Error, ...) при ошибке | R-RES-OBS-2 | RecordError + SetStatus при любом err != nil |
| Метрики без алёртов в Prometheus/Alertmanager | R-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.Asfallback, кеш, частичный ответ - Health checks — TTL-кеш,
GET /health, readiness - Связка с OpenAPI-генератором —
oapi-codegen, mapper generated → domain - Per-system isolation — один
*http.Clientper system - Retry —
retry.Do,BackOffDelay,RetryIf - Timeouts —
capTimeout,Transport.ResponseHeaderTimeout - Где какая защита — outbound vs inbound, task-queue