Skip to main content

Observability

Shields are observable without any setup: they describe themselves as strings, publish metrics through a built-in Meter, and an analyzer package catches the most common resilience mistake at compile time.

Pipeline descriptions

shield.ToString() prints the whole pipeline, outermost strategy first, with each strategy's configuration:

var shield = Shield
.Timeout(TimeSpan.FromSeconds(30))
.Retry(3)
.CircuitBreaker(5, TimeSpan.FromSeconds(30))
.WithName("github");

Console.WriteLine(shield);
// github: Timeout(30s) → Retry(3, exponential 250ms ×2 +jitter ≤30s) → CircuitBreaker(5 consecutive, break 30s)

Log it once at startup and every incident review starts from the actual configuration, not the configuration someone remembers. Custom strategies participate by overriding Strategy.Describe().

Metrics

On .NET 8+ every shield publishes metrics through a System.Diagnostics.Metrics.Meter named Kevlar, version 1.0 — zero configuration, and effectively free (an enabled check per instrument) until something listens. Subscribe with OpenTelemetry:

services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(KevlarDiagnostics.MeterName));
InstrumentUnitCountsAttributes
kevlar.executions{execution}completed public execution calls, including empty shields and pre-cancelled callskevlar.shield.name, kevlar.execution.outcome (success/failure)
kevlar.retries{retry}retry attemptskevlar.shield.name
kevlar.timeouts{timeout}executions cancelled by a timeout strategykevlar.shield.name
kevlar.hedges{hedge}extra hedged attempts launchedkevlar.shield.name
kevlar.fallbacks{fallback}outcomes replaced by a fallbackkevlar.shield.name
kevlar.rejections{rejection}fail-fast rejectionskevlar.shield.name, kevlar.rejection.type (circuit_open/rate_limit/concurrency_limit)
kevlar.circuit_breaker.transitions{transition}circuit state changeskevlar.circuit_breaker.state.from, kevlar.circuit_breaker.state.to (closed/open/half_open/isolated)
kevlar.execution.durationshistogram of completed public execution durationkevlar.shield.name, kevlar.execution.outcome (success/failure)
kevlar.circuit_breaker.state{state}last observed circuit state: closed 0, open 1, half-open 2, isolated 3kevlar.shield.name
kevlar.concurrency_limit.inflight{execution}executions holding a permitkevlar.shield.name
kevlar.concurrency_limit.queued{execution}executions waiting for a permitkevlar.shield.name
kevlar.concurrency_limit.capacity{execution}configured concurrency permit capacitykevlar.shield.name
kevlar.rate_limit.available{permit}immediately available burst permits at the last limiter operationkevlar.shield.name
kevlar.rate_limit.queued{execution}executions waiting for a rate-limit permitkevlar.shield.name

Each public execution call records exactly one kevlar.executions measurement after its final outcome: recovery through fallback is success; exceptions, caller cancellation, timeout, and strategy rejection are failure. Retry and hedge attempts do not add execution measurements of their own.

The kevlar.shield.name attribute appears only for shields named via WithName — name the shields you plan to chart. WithName("") emits the attribute with an empty value; an unnamed shield omits it. Instrument and attribute names use the product-specific kevlar namespace; count units use singular UCUM annotations. Counters and the duration histogram require .NET 8 or later; the shipped state gauges require .NET 10 or later. On netstandard2.0 targets the instruments are inert because the metrics API isn't in-box there.

The gauges are synchronous last-value measurements emitted when strategy state changes. They aggregate by shield name and carry a bounded kevlar.strategy.index attribute (the strategy's zero-based pipeline position), so independent stateful strategies in one named pipeline remain distinct. Shared strategies update up to 64 observed name/index aliases; additional aliases omit state-gauge measurements to bound memory, transition work, and series growth. The gauges do not use observable callbacks or global strategy registries, so telemetry never keeps an abandoned shield alive. A typical Prometheus export can query p95 latency and queue saturation with:

histogram_quantile(0.95, sum by (le) (rate(kevlar_execution_duration_seconds_bucket[5m])))
max by (kevlar_shield_name) (kevlar_concurrency_limit_queued)

Exporter naming rules vary; inspect the exported names if your backend applies a different dot/unit translation.

Telemetry overhead

BenchmarkDotNet ShortRun results on .NET 10.0.11, Windows 11, and an Intel Core i7-12700K measured no managed allocation in any case. Listener-enabled timings include an empty MeterListener receiving every Kevlar instrument:

PipelineListener offListener onAllocated
Empty shield3 ns237 ns0 B
Retry96 ns322 ns0 B
Circuit breaker114 ns432 ns0 B
Rate limit84 ns577 ns0 B
Concurrency limit131 ns636 ns0 B

These figures are a local comparison rather than a performance guarantee. Run TelemetryBenchmarks on the deployment hardware to measure exporter and listener costs in that environment.

Compile-time checks

The Kevlar.Analyzers package ships Roslyn analyzers for mistakes that are otherwise invisible until an incident:

dotnet add package Kevlar.Analyzers
RuleSeverityCatches
KEV001WarningAn execution delegate that never uses its effective CancellationToken — passed directly by ordinary execution APIs or exposed as context.CancellationToken by context-aware APIs. Ignoring it is the most common way to defeat a timeout.
KEV002WarningA statically known multi-attempt hedging pipeline passed to synchronous Execute.
KEV003WarningAn inner fallback that makes retry, hedging, or circuit breaker unreachable under the same handling clause.
await shield.ExecuteAsync(ct => client.GetAsync(url)); // KEV001: token ignored
await shield.ExecuteAsync(ct => client.GetAsync(url, ct)); // clean

See Analyzer rules for rationale, safe alternatives, conservative analysis limits, and suppression guidance.

Callbacks

Strategy callbacks provide request-level logging where configured. Retry, circuit breaker, timeout, and result-aware fallback expose synchronous and asynchronous callbacks. Hedging exposes a synchronous callback only. Concurrency limit and rate limit expose no callback APIs. Each callback is documented on its strategy page. Metrics tell you how much; callbacks give you the which request detail.