Telemetry
When a call is slow or fails in production, you need visibility into the policy's behavior - such as which attempts are being retried, how long they take, whether a circuit breaker opened, or if the retry budget is exhausted. Telemetry provides this visibility through a single event stream.
Telemetry is enabled by default for policies registered in a container. For policies built manually, it is opt-in. If OnEvent is null, the executor raises no events and incurs no performance overhead.
The telemetry system uses a single struct, CallEvent, and a single delegate, Resilience.OnEvent.
Attach a listener
You can attach a listener to a policy to log or record events.
var api = Resilience.Http with
{
Name = "payments",
Backoff = Backoff.None,
OnEvent = e => _logger.LogInformation(
message: "{Policy} {Kind} attempt {Attempt}: {Verdict} in {Ms}ms",
e.PolicyName, e.Kind, e.AttemptNumber, e.Verdict.Kind, e.Duration.TotalMilliseconds),
};The listener is synchronous and runs on the same thread as the executor. To avoid blocking the call, only perform fast operations such as logging, counting, or enqueuing; do not perform synchronous I/O. Any exception thrown by a listener is swallowed to prevent telemetry from failing the operation it is observing.
To use multiple listeners, combine them using the + operator: OnEvent = first + second.
A lambda is still the right answer for anything that is not an ILogger. If it is, a ready-made listener already exists and says what each event means: see Logging.
Event types
| Kind | Description | Terminal? |
|---|---|---|
Attempt | An attempt finished, regardless of the verdict | No |
Retrying | A retry was decided and the backoff delay is about to start | No |
Succeeded | The call succeeded | Yes |
NotRetried | The outcome was Permanent | Yes |
Exhausted | The final attempt failed and no retries remain | Yes |
Rejected | A circuit breaker or the retry budget refused the call | Yes |
DeadlineExceeded | The total wall-clock budget expired | Yes |
OrphanedWork | A callback ran past the timeout that should have stopped it | No |
BreakerOpened / BreakerClosed / BreakerHalfOpened | A circuit breaker changed state | No |
NestedRetry | The request is already inside another retrying client | No |
Every call ends with exactly one terminal event. This invariant ensures that counts of logical operations are accurate.
var api = Resilience.Default with { Backoff = Backoff.None, OnEvent = events.Record };
await api.RunAsync(attempt => calls.NextAsync(cancellationToken: attempt), cancellationToken: cancellationToken);
// Attempt, Retrying, Attempt, Succeeded
Console.WriteLine(value: string.Join(separator: ", ", values: events.Kinds));Durationrepresents the individual attempt's duration forAttemptevents, and the total elapsed time for all other event types.Delayrepresents the pause about to be served forRetryingandRejectedevents; it isnullfor other events.Reasondistinguishes between the two types of refusals covered by aRejectedevent.
// [PolicyName] Kind #N VerdictKind ExceptionType (duration) +delay
Console.WriteLine(value: events[index: 0]); // [api] Attempt #1 Ok (0.1ms)For more details, see the CallEvent reference.
Metrics and traces
The NResilience.Extensions package provides a meter, an activity source, and a listener that feeds both.
// A policy registered in a container is instrumented for you. A policy in a static field
// is not - this says it.
var api = (Resilience.Http with { Name = "payments" }).WithTelemetry();| Instrument | Unit | Description |
|---|---|---|
nresilience.calls | {call} | Total logical operations |
nresilience.attempts | {attempt} | Total wire-level attempts |
nresilience.rejections | {rejection} | Calls refused by a guard, tagged dependency_unavailable or budget_exhausted |
nresilience.call.duration | s | End-to-end duration of a logical operation |
nresilience.attempt.duration | s | Duration of a single attempt |
nresilience.limiter.leases | {lease} | Permits a limiter was asked for, tagged acquired or denied |
nresilience.limiter.wait.duration | s | How long a caller waited on a limiter. Zero unless queueing is enabled |
The retry fraction is calculated as nresilience.attempts ÷ nresilience.calls. This is the primary metric for monitoring retry feedback loops and identifying potential retry storms.
For more information, see Telemetry in DI.
