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

Важно знать

  • cockatiel не имеет авто-экспорта метрик — нужно явно подписаться на события policy (onBreak, onReset, onHalfOpen, onFailure, onSuccess) и зарегистрировать gauges/counters через prom-client.
  • opossum отдаёт status-снапшоты из коробки, но cockatiel предпочтителен как единый composable-слой; в нём метрики нужно добавить самостоятельно — один раз в фабрике политик.
  • OTel-span на adapter-методе с атрибутами circuit_breaker.state и external.system даёт связку «slow trace → CB был half-open» без лишних запросов к базе событий.
  • Structured WARN-лог пишется только на state-transitions CB (closed→open, open→half-open, half-open→closed), не на каждый успешный вызов.
  • Без метрик SRE не увидит залипший CB в half-open раньше, чем клиент напишет об ошибке.
  • Минимальный Grafana-дашборд: cb_state timeseries по системам, call rate success/failure, bulkhead utilization, latency p95 outbound.
  • Алёрты: CB в open > 5 минут; bulkhead_active / bulkhead_max >= 0.9 стабильно; cb_failures_total rate растёт.

Защита от отказов бесполезна, если о её срабатываниях никто не узнаёт. В отличие от Resilience4j, cockatiel не регистрирует метрики автоматически, но предоставляет события на каждое изменение состояния — нужно только подписаться в фабрике политик.

Метрики через prom-client

R-RES-OBS-1: cockatiel выбрасывает события через policy.onBreak, onReset, onHalfOpen, onFailure, onSuccess; bulkhead — через executionSlots. Эти события нужно транслировать в counters/gauges prom-client один раз при сборке policy-набора.

// src/adapters/out/sber/sber.policy.ts
import { circuitBreaker, CountBreaker, retry, bulkhead, timeout,
         TimeoutStrategy, wrap, ExponentialBackoff, handleType } from 'cockatiel';
import { Counter, Gauge, Registry } from 'prom-client';
import { SberTransientError } from './sber.errors';

export function buildSberPolicy(registry: Registry) {
  const cbState = new Gauge({
    name: 'cb_state',
    help: 'Circuit breaker state (0=closed, 1=open, 2=half_open)',
    labelNames: ['system'],
    registers: [registry],
  });
  const cbFailures = new Counter({
    name: 'cb_failures_total',
    help: 'Circuit breaker failure count',
    labelNames: ['system'],
    registers: [registry],
  });
  const cbCalls = new Counter({
    name: 'cb_calls_total',
    help: 'Total calls through circuit breaker',
    labelNames: ['system', 'result'],
    registers: [registry],
  });
  const bulkheadActive = new Gauge({
    name: 'bulkhead_active',
    help: 'Currently active bulkhead slots',
    labelNames: ['system'],
    registers: [registry],
  });
  const bulkheadMax = new Gauge({
    name: 'bulkhead_max',
    help: 'Maximum bulkhead slots',
    labelNames: ['system'],
    registers: [registry],
  });

  const cb = circuitBreaker(handleType(SberTransientError), {
    halfOpenAfter: 30_000,
    breaker: new CountBreaker({ threshold: 0.5, size: 50 }),
  });

  cbState.set({ system: 'sber' }, 0);
  bulkheadMax.set({ system: 'sber' }, 8);

  cb.onBreak(() => {
    cbState.set({ system: 'sber' }, 1);
  });
  cb.onHalfOpen(() => {
    cbState.set({ system: 'sber' }, 2);
  });
  cb.onReset(() => {
    cbState.set({ system: 'sber' }, 0);
  });
  cb.onFailure(() => {
    cbFailures.inc({ system: 'sber' });
    cbCalls.inc({ system: 'sber', result: 'failure' });
  });
  cb.onSuccess(() => {
    cbCalls.inc({ system: 'sber', result: 'success' });
  });

  const bh = bulkhead(8, 0);
  bh.onReject(() => {
    cbCalls.inc({ system: 'sber', result: 'rejected' });
  });

  return { policy: wrap(
    retry(handleType(SberTransientError), { maxAttempts: 3, backoff: new ExponentialBackoff() }),
    cb,
    bh,
    timeout(5_000, TimeoutStrategy.Aggressive),
  ), bulkheadActive, system: 'sber' };
}

Зарегистрировать фабрику как DI-провайдер один раз в модуле адаптера:

// src/adapters/out/sber/sber.module.ts
@Module({
  providers: [
    {
      provide: SBER_POLICY,
      useFactory: (registry: PrometheusRegistry) => buildSberPolicy(registry.registry),
      inject: [PrometheusRegistry],
    },
    SberAdapter,
  ],
})
export class SberModule {}

Что появляется в /metrics (Prometheus-формат):

cb_state{system="sber"} 0
cb_failures_total{system="sber"} 42
cb_calls_total{system="sber",result="success"} 1234
cb_calls_total{system="sber",result="failure"} 42
cb_calls_total{system="sber",result="rejected"} 3
bulkhead_active{system="sber"} 3
bulkhead_max{system="sber"} 8

Что из этого строится в Grafana:

  • Per-system панель: current CB state (0/1/2), success/failure rate, failure rate %.
  • Bulkhead utilization: bulkhead_active / bulkhead_max — процент занятости.
  • Call funnel: success vs failure vs rejected (bulkhead) за скользящее окно.

OTel-spans с атрибутами

R-RES-OBS-2: на каждый вызов adapter-метода создаётся span с атрибутами external.system и circuit_breaker.state. Атрибут отражает состояние CB в момент входа в вызов — это ключ для root-cause анализа «slow trace 4.8s → span sber.findOrder с circuit_breaker.state=half_open».

// src/adapters/out/sber/sber.adapter.ts
import { Injectable, Inject } from '@nestjs/common';
import { trace, context, SpanStatusCode } from '@opentelemetry/api';
import { BrokenCircuitError, BulkheadRejectedError, TaskCancelledError } from 'cockatiel';
import { SBER_CLIENT, SBER_POLICY } from './sber.tokens';
import { OrderRef } from '@core/order/order.ref';
import { Order } from '@core/order/order';
import { OrderPortError } from '@core/order/order.port.error';
import { toOrderDomain } from './sber.mapper';

@Injectable()
export class SberAdapter {
  private readonly tracer = trace.getTracer('sber-adapter');

  constructor(
    @Inject(SBER_CLIENT) private readonly client: import('undici').Agent,
    @Inject(SBER_POLICY) private readonly sberPolicy: ReturnType<typeof buildSberPolicy>,
  ) {}

  async findOrder(ref: OrderRef): Promise<Order> {
    const cbStateName = this.currentCbState();
    const span = this.tracer.startSpan('sber.findOrder', {
      attributes: {
        'external.system': 'sber',
        'circuit_breaker.state': cbStateName,
      },
    });

    return context.with(trace.setSpan(context.active(), span), async () => {
      try {
        const resp = await this.sberPolicy.policy.execute(() =>
          this.client.request({ origin: 'https://sber.example', path: `/orders/${ref.id}`, method: 'GET' }),
        );
        const dto = await resp.body.json();
        span.setStatus({ code: SpanStatusCode.OK });
        return toOrderDomain(dto);
      } catch (e) {
        span.recordException(e as Error);
        span.setStatus({ code: SpanStatusCode.ERROR });
        if (e instanceof BrokenCircuitError) throw OrderPortError.systemUnavailable('sber', e);
        if (e instanceof BulkheadRejectedError) throw OrderPortError.systemBusy('sber', e);
        if (e instanceof TaskCancelledError) throw OrderPortError.timeout('sber', e);
        throw OrderPortError.from(e);
      } finally {
        span.end();
      }
    });
  }

  private currentCbState(): string {
    return 'unknown'; // получается из инстанса CB через sberPolicy при наличии экспоузера состояния
  }
}

Что даёт связь с трейсингом:

  • В Jaeger / Tempo виден span sber.findOrder с circuit_breaker.state=half_open на медленном trace.
  • Алёрт «slow outbound p95 > 3s» можно сопровождать атрибутом circuit_breaker.state для drill-down без переключения экранов.
  • При BrokenCircuitError span получает status=ERROR и recordException — видно в трейсе без дополнительного лога.

Structured logging при state-transition

R-RES-OBS-3: лог уровня WARN пишется только при смене состояния CB — closed→open, open→half-open, half-open→closed. Не на каждый успешный вызов и не на каждую ошибку.

// src/adapters/out/sber/sber.policy.ts — дополнение к buildSberPolicy
import { Logger } from '@nestjs/common';

export function buildSberPolicy(registry: Registry) {
  const logger = new Logger('SberCircuitBreaker');

  // ... метрики выше ...

  cb.onBreak((reason) => {
    cbState.set({ system: 'sber' }, 1);
    logger.warn({
      msg: 'circuit breaker state transition',
      system: 'sber',
      prev_state: 'closed',
      new_state: 'open',
      reason: reason?.message ?? 'threshold exceeded',
    });
  });

  cb.onHalfOpen(() => {
    cbState.set({ system: 'sber' }, 2);
    logger.warn({
      msg: 'circuit breaker state transition',
      system: 'sber',
      prev_state: 'open',
      new_state: 'half_open',
    });
  });

  cb.onReset(() => {
    cbState.set({ system: 'sber' }, 0);
    logger.warn({
      msg: 'circuit breaker state transition',
      system: 'sber',
      prev_state: 'half_open',
      new_state: 'closed',
    });
  });

  // ...
}

Пример записи в stdout (pino/winston JSON):

{
  "level": "warn",
  "msg": "circuit breaker state transition",
  "system": "sber",
  "prev_state": "closed",
  "new_state": "open",
  "reason": "threshold exceeded",
  "timestamp": "2026-06-20T10:12:33.421Z"
}

Что важно:

  • Только transitionsonBreak / onHalfOpen / onReset. Не onFailure (per-call шум).
  • WARN-уровень: ERROR — слишком крикливо и не отражает автоматически восстанавливающийся CB; INFO — тихо, алёрты по WARN обычны.
  • Структурированный объект: поля system, prev_state, new_state — чтобы Loki/ELK выдавал готовый фильтр без разбора строки.
  • Для BulkheadRejectedError в adapter-методе — дополнительный INFO-лог с system и счётчиком: позволяет видеть моменты насыщения в логах рядом с метриками.

Для CustomerAdapter (ProductAdapter, ReceiptAdapter) паттерн идентичен — только имя системы меняется. Фабрику buildPolicy(system, registry, options) можно параметризовать.

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

АнтипаттернПравилоЧто взамен
Нет подписок на события policy — нет метрик resilienceR-RES-OBS-X1onBreak/onReset/onHalfOpen/onFailure в фабрике policy
Лог на каждый onFailure (per-call шум)R-RES-OBS-3Только на state-transitions CB
console.error вместо структурированного логгераR-RES-OBS-3NestJS Logger с JSON-форматтером (pino/winston)
Span без атрибутов external.system и circuit_breaker.stateR-RES-OBS-2Атрибуты в startSpan({ attributes: {...} })
Только метрики без алёртов в GrafanaR-RES-OBS-1Алёрты на cb_state, bulkhead utilization, cb_failures_total rate
Общий registry: Registry переданный напрямую в несколько policy без labelNames — метки сливаютсяR-RES-OBS-1system-метка в каждом gauge/counter, per-system инстансы

Куда дальше

  • Circuit Breaker — state transitions и CountBreaker-конфигурация.
  • Bulkhead — bulkhead(maxConcurrent, queueLimit) и sizing.
  • Retry — ExponentialBackoff, handleType, метрики retry.
  • Health checks — @nestjs/terminus custom indicator с TTL-кешем.
  • Async и polling — task-queue вместо setTimeout-цикла.
  • Конфигурация — типизированный конфиг через zod/class-validator.
  • Per-system isolation — отдельный Agent и policy-набор на систему.
  • Timeouts — иерархия connect < headers < total.
  • Fallback — BrokenCircuitError → кеш/деградация.
  • Circuit Breaker — onBreak/onReset events подробно.
  • OpenAPI generator binding — openapi-typescript + адаптер.
  • Where protection goes — где ставить policy, где нет.