Опирается на правила:
R-RES-OBS-1…R-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_statetimeseries по системам, call rate success/failure, bulkhead utilization, latency p95 outbound.- Алёрты: CB в
open> 5 минут;bulkhead_active / bulkhead_max >= 0.9стабильно;cb_failures_totalrate растёт.
Защита от отказов бесполезна, если о её срабатываниях никто не узнаёт. В отличие от 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 без переключения экранов. - При
BrokenCircuitErrorspan получает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"
}
Что важно:
- Только transitions —
onBreak/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 — нет метрик resilience | R-RES-OBS-X1 | onBreak/onReset/onHalfOpen/onFailure в фабрике policy |
Лог на каждый onFailure (per-call шум) | R-RES-OBS-3 | Только на state-transitions CB |
console.error вместо структурированного логгера | R-RES-OBS-3 | NestJS Logger с JSON-форматтером (pino/winston) |
Span без атрибутов external.system и circuit_breaker.state | R-RES-OBS-2 | Атрибуты в startSpan({ attributes: {...} }) |
| Только метрики без алёртов в Grafana | R-RES-OBS-1 | Алёрты на cb_state, bulkhead utilization, cb_failures_total rate |
Общий registry: Registry переданный напрямую в несколько policy без labelNames — метки сливаются | R-RES-OBS-1 | system-метка в каждом gauge/counter, per-system инстансы |
Куда дальше
- Circuit Breaker — state transitions и
CountBreaker-конфигурация. - Bulkhead —
bulkhead(maxConcurrent, queueLimit)и sizing. - Retry —
ExponentialBackoff,handleType, метрики retry. - Health checks —
@nestjs/terminuscustom indicator с TTL-кешем. - Async и polling — task-queue вместо
setTimeout-цикла. - Конфигурация — типизированный конфиг через zod/class-validator.
- Per-system isolation — отдельный
Agentи policy-набор на систему. - Timeouts — иерархия
connect < headers < total. - Fallback —
BrokenCircuitError→ кеш/деградация. - Circuit Breaker —
onBreak/onResetevents подробно. - OpenAPI generator binding —
openapi-typescript+ адаптер. - Where protection goes — где ставить policy, где нет.