One event per call
How NResilience handles telemetry: one terminal event per operation, the retry fraction, and allocation-free observation.
TL;DR: Every resilient operation in NResilience ends with exactly one terminal CallEvent. By splitting logical calls from wire-level attempts, you get a single, reliable metric - the retry fraction - that tells you if you're serving errors or fueling a retry storm.
The telemetry gap
Most resilience libraries log from the perspective of the attempt. You see a "Retry 1" log, then a "Retry 2" log, and eventually a success or a failure. This is fine for debugging a single request, but it's useless for operating a fleet.
When you're looking at a dashboard during an incident, you don't care that a specific request retried twice. You care about the aggregate: is my system performing 1.1x the expected work, or 5x? If you only have "per-attempt" metrics, you have to guess the logical call volume by deduplicating trace IDs or relying on a separate high-level span that might be missing or sampled differently.
NResilience treats the logical operation as the primary unit of account. Every call, regardless of how many attempts it takes, raises exactly one terminal event.
Anatomy of a CallEvent
The CallEvent is the entire telemetry surface of the library. Instead of a family of differently-shaped event types, everything - from a successful return to a circuit breaker refusal - is a CallEvent.
It is a readonly struct (64 bytes), passed by value to listeners. This means raising an event allocates nothing. If you don't have a listener attached, the executor suppresses the event logic entirely, so the cost is zero.
The most useful part of the event for operators is the ToString() output, which follows a strict, predictable format:
[PolicyName] Kind #Attempt VerdictKind ExceptionType (duration) +delay
Here is what that looks like for a call that retried once and succeeded:
[Api] Attempt #1 Transient HttpRequestException (102ms)
[Api] Retrying #1 Transient (102ms) +200ms
[Api] Attempt #2 Ok (45ms)
[Api] Succeeded #2 Ok (347ms)
In this sequence, you see two attempts, one retry, and one terminal event (Succeeded). The duration in the terminal event is the total wall-clock time the caller waited.
Listening to the executor
You can hook into these events using the OnEvent property on your policy.
var apiPolicy = Resilience.Http with
{
OnEvent = e =>
{
if (e.IsTerminal)
{
Console.WriteLine($"Call ended: {e.Kind} in {e.Duration.TotalMilliseconds}ms");
}
}
};
To add a listener rather than replace one, use WithListener:
var counted = apiPolicy.WithListener(e => Metrics.Record(e.Kind));
That distinction matters more than it looks. Assigning OnEvent in a with expression replaces whatever was there - and a policy registered in a container already has telemetry and logging attached, so a with { OnEvent = mine } silently turns both off. WithListener adds to the chain, which is exactly what WithTelemetry() and WithLogging() do to each other. Listeners run in the order they were added.
Because the event is a struct, you can leave these listeners attached in production without worrying about GC pressure. The library only populates the Result property (which boxes the return value) if a listener is actually present, ensuring that the "happy path" remains allocation-free.
Automatic telemetry with WithTelemetry()
While custom listeners are great for logging, you usually want your metrics in Prometheus or Grafana. The NResilience.Extensions package provides WithTelemetry(), which attaches a pre-configured listener that pipes events into System.Diagnostics.Metrics and System.Diagnostics.Activity. A policy registered in a container is instrumented for you; a policy in a static field needs this call.
using NResilience.Extensions;
var policy = Resilience.Http
.WithTelemetry();
// In your DI setup
builder.Services.AddOpenTelemetry()
.WithMetrics(m => m.AddMeter("NResilience"))
.WithTracing(t => t.AddSource("NResilience"));
In an HTTP pipeline, AddResilience() on the client builder attaches telemetry as part of registration and starts a single logical span for the entire operation. All subsequent attempt events, retry delays, and breaker transitions are added as events to that one span. This gives you a clean, single-entry trace for every logical request.
The most important number: The Retry Fraction
The goal of this architecture is to make one specific number easy to calculate: the retry fraction.
In your metrics provider, you'll see two primary counters:
nresilience.calls: Logical operations (one per call)nresilience.attempts: Wire-level attempts
The formula is simple: nresilience.attempts / nresilience.calls.
If this number is 1.0, you have no retries. If it's 1.1, you're paying a 10% tax in extra load to hide transient failures - usually an acceptable trade. If it jumps to 3.0 or 5.0, you are in a retry storm. Your clients are amplifying the load on a struggling dependency, likely making the outage worse.
By splitting these counters, NResilience turns a "feeling" about system load into a hard, observable metric.
Monitoring the Tail
Telemetry isn't just about counting failures; it's about seeing the system adapt. NResilience exposes its internal measurements through the same event system.
If you use hedging, you'll see nresilience.hedges and nresilience.hedge.threshold. The threshold is a histogram of the latency quantile that triggered the hedge.
Watching this move during an incident is how you tell the difference between a brownout and a tail:
- Tail: The threshold stays stable, but a few requests are very slow. Hedges win often.
- Brownout: The threshold starts climbing. The dependency is slowing down for everyone. Hedges start failing or taking as long as the original call.
Similarly, if you use measured bounds, the nresilience.attempt.ceiling and nresilience.backoff.base histograms tell you exactly when the library tightened a constant: each is recorded whenever the measured value moves, so a gap between the histogram and the constant you wrote is a measurement telling you your guess was off.
Four newer kinds round out the surface, each following the same one-terminal-event rule:
SaturationDetected- raised once at the onset of a thread-pool queue episode, so a count of these is a count of local incidents, not a per-call rate.RejectedByQuota- the HTTP handler refused one attempt because the dependency's published allowance is spent. It refuses an attempt rather than the call, so it is neither terminal nor anIsRejection.StreamResumed- a checkpointed stream or stalled HTTP body picked itself back up;AttemptNumberis the restart.Draining- the process is shutting down and the call stopped with the failure it already had instead of retrying. It is terminal, and it is a dependency failure, not a library refusal.
The cost of observation
The core philosophy of NResilience is that you shouldn't pay for what you don't use.
If OnEvent is null, the executor performs a single null check before every event. It does not create CallEvent objects, it does not box results, and it does not allocate strings.
When you attach WithTelemetry(), the cost is a few bytes for the delegate and the overhead of the System.Diagnostics calls. Because we use readonly structs for the events, we avoid the common trap of allocating a new "Event" object for every single attempt in a high-throughput system.
Conclusion
Telemetry should be an asset, not a bottleneck. By centering the library around a single, allocation-free CallEvent and distinguishing between calls and attempts, NResilience gives you the data needed to run a fleet safely. You can see exactly what the executor is deciding, why it's deciding it, and exactly how much extra load your resilience is costing you.
The Telemetry documentation lists every instrument and tag. For the other half of the observability story, Logs that don't flood covers how the library keeps log noise under control during incidents.