Diagnosing Failures
A message failed in production. How do you find out why?
This page ties together the signals Benzene emits when something goes wrong in a message handler or in the middleware pipeline: what you get out of the box with nothing configured, the recommended middleware stack that makes failures easy to trace, the log fields each layer adds, and how the same failure shows up across logs, traces, and metrics. For setting up observability backends see Monitoring & Diagnostics; for catching exceptions and mapping them to responses see Global Error Handling.
Two kinds of failure
Benzene distinguishes between two failure modes, and they surface differently:
- An unsuccessful result — a handler returns
BenzeneResult.NotFound(...),BenzeneResult.BadRequest(...),BenzeneResult.UnexpectedError(...), etc. This is a normal return value: the response pipeline serializes anErrorPayloadand maps the status to the transport (an HTTP 404/400/500, an SQS batch-item-failure, ...). Nothing threw. - A thrown exception — a handler (or middleware, or a mapper) throws. This bypasses the response pipeline entirely; how it settles depends on the transport (see the catch matrix).
The distinction matters when you're reading logs: an unsuccessful result and an exception are different events with different log lines, and a stack trace only exists for the second kind.
What you get with nothing wired
Even with no logging or diagnostics middleware added, Benzene emits a baseline error signal so failures aren't completely silent:
| Situation | Signal | Source |
|---|---|---|
| Topic missing from the message | Warning "Topic is missing" |
MessageRouter |
| No handler registered for the topic | Warning "No handler found for topic " |
MessageRouter |
| Handler returns an unsuccessful result | Warning "Handler for topic returned unsuccessful status " |
MessageRouter |
| Handler/middleware throws | Transport-dependent — see the catch matrix | the transport application |
These are enough to answer "did my handler run, and did it fail?" from a plain log tail — but they carry no correlation id, no trace id, and (for the baseline) no per-message processing time. Wiring the recommended stack below gives you all of that.
These log lines only reach a sink if a logging provider is configured.
UsingBenzene(...)callsAddLogging()soILogger<T>resolves, but with no provider every log call is a no-op. Add a provider (AddLogging(x => x.AddConsole()), Serilog, Application Insights, ...) or you'll see nothing regardless of what middleware you add. See Monitoring — Logging.
The recommended stack
Add these to each transport pipeline, in this order, before .UseMessageHandlers():
app.UseSqs(sqsApp => sqsApp
.UseW3CTraceContext() // 1. FIRST — continue the caller's distributed trace
.UseBenzeneEnrichment() // 2. attach invocationId/traceId/topic/transport/handler to every log
.UseExceptionHandler((SqsMessageContext ctx, Exception ex) => ctx.IsSuccessful = false) // 3. catch + log thrown exceptions
.UseLogResult(_ => { }) // 4. one structured log line per message (Info on success, Error on throw)
.UseMessageHandlers());
and, once, in ConfigureServices:
services.UsingBenzene(x => x
.AddMessageHandlers(typeof(HelloWorldMessageHandler).Assembly)
.AddDiagnostics()); // Activity span per middleware — marks failing spans with Error status
What each layer contributes when something fails:
| Layer | On failure it gives you | Notes |
|---|---|---|
AddDiagnostics() |
The failing span is marked Error with an exception event and stack, tagged benzene.transport/benzene.topic/benzene.version/benzene.handler |
Spans are a no-op until an OTel listener is attached; near-zero cost otherwise |
UseW3CTraceContext() |
The trace continues from the caller instead of starting fresh, so the failure is on the same trace as whatever triggered it | Must be first; safe when the header is absent |
UseBenzeneEnrichment() |
invocationId, traceId, spanId, topic, transport, handler on every log line in the pipeline (each omitted gracefully if unavailable) |
Portable across all transports; replaces hand-composed WithXxx() calls |
UseExceptionHandler(...) |
An Error log — "Unhandled exception caught in middleware pipeline" — plus your callback decides how the transport settles |
The callback is transport-specific; see Global Error Handling |
UseLogResult(...) |
One line per message: Info "BenzeneResult" on success, Error "BenzeneResult faulted" (with the exception) on a throw, both with processTime |
The Error line is what stops a throw from silently skipping the "log every message" line |
Add UseBenzeneMetrics() too if you want the failure to
also move a counter/histogram — see below.
How a failure shows up across the three signals
The same failing message produces a coordinated picture once the stack is wired:
In logs
A thrown InvalidOperationException("boom") handling order:create on SQS, with the stack above:
[Error] Benzene: BenzeneResult faulted
System.InvalidOperationException: boom
{ processTime=42, invocationId=..., traceId=4bf92f..., topic=order:create, transport=sqs, handler=CreateOrderHandler }
[Error] Benzene: Unhandled exception caught in middleware pipeline
System.InvalidOperationException: boom
at MyApp.CreateOrderHandler.HandleAsync(...)
{ invocationId=..., traceId=4bf92f..., topic=order:create, transport=sqs, handler=CreateOrderHandler }
UseLogResult sits inside UseExceptionHandler, so the innermost catch logs first ("faulted",
with processTime), then the exception handler's net logs and settles the message. Both lines
describe the same throw from different layers — the traceId ties them together.
An unsuccessful result (no throw) instead produces a single Warning from the router:
[Warning] Benzene: Handler CreateOrderHandler for topic order:create returned unsuccessful status NotFound
{ invocationId=..., topic=order:create, transport=sqs, handler=CreateOrderHandler }
Grep tips: traceId ties every line of one message together (and across services); topic +
status narrows to a failure kind; transport separates which adapter handled it.
In traces
With AddDiagnostics() and an OTel exporter (Monitoring — OpenTelemetry),
the failing middleware's span carries status = Error and an exception event with the stack, so a
trace viewer (Jaeger, Tempo, Application Insights) points straight at the stage that threw — the
MessageRouter span for a handler throw. Every span is tagged with the topic/handler, and
UseW3CTraceContext() keeps it on the caller's trace.
In metrics
With UseBenzeneMetrics(), every message moves two
instruments on the "Benzene" meter, tagged topic/transport/result:
benzene.messages.processed(counter) — theresulttag lets you alert on a rising rate of a specific failure status for a topicbenzene.message.duration(histogram, ms) — a failure that's actually a timeout shows up here as a latency spike, not just an error count
Ordering footguns
A few middleware silently do nothing if wired in the wrong order — no error, just missing data:
UseW3CTraceContext()must be the first middleware. Everything after it inherits the correct ambientActivity.Currentparent; put it later and the spans before it start a new, disconnected trace and inboundtraceparentcontinuation is lost.- Enrichment's
invocationIdneedsUseBenzeneInvocation()upstream. On the batch/per-message transports (SQS, SNS, Kafka, Event Hub) this is wired automatically per record; on a hand-built pipeline, if you wantinvocationIdyou need the invocation populated beforeUseBenzeneEnrichment()runs, or that one field is simply omitted. UseExceptionHandler(...)only wraps what comes after it. Register it before.UseMessageHandlers()(and before anything else whose throws you want caught). Anything earlier in the chain is unprotected.UseLogResult(...)should sit outside the handlers so itsprocessTimecovers the whole pipeline and it logs the result the handlers produced.
For the first of these, there's an opt-in startup check: IServiceResolver.LogPipelineOrderingIssues(builder)
(Benzene.Diagnostics) warns if UseW3CTraceContext() isn't the first middleware in a pipeline.
Call it once after building a pipeline; it's advisory and never throws. (The enrichment/invocation
rule isn't machine-checkable — the batch transports auto-wire UseBenzeneInvocation inside a
per-message sub-pipeline the check can't see — so that one stays a documented footgun.)
What reaches your logs per transport
When a handler throws, what Benzene logs (before any middleware you add) depends on the transport application that owns the invocation:
| Transport | Exception handling | Benzene-logged with context? |
|---|---|---|
AWS Lambda SQS (SqsApplication) |
catch per record → batch-item-failure | Yes — Error "Processing SQS message failed" |
AWS Lambda SNS (SnsApplication) |
catch when CatchExceptions |
Yes — Error (when catching) |
| AWS Lambda DynamoDb / Kinesis | catch → checkpoint/stop | Yes — Error per record |
| AWS Lambda S3 / EventBridge / Kafka | no catch — propagates to the Lambda host | Only the Lambda runtime's raw error (no Benzene context; whole invocation fails) |
| Self-hosted SQS / Kafka / RabbitMQ / Event Hub / Cosmos / HTTP workers | catch per message → ack/nack | Yes — Error per message |
Self-hosted Azure Service Bus (BenzeneServiceBusWorker) |
catch → abandon + rethrow | Yes — Error per message with the message id, plus the receive-side error handler |
| Azure Functions triggers | RaiseOnFailureStatus escalation → Functions host |
Host logs; Benzene adds structured logs where the app catches |
| HTTP (ASP.NET Core / API Gateway / SelfHost) | maps to a status code | Exception → the app's error path / UseExceptionHandler if wired |
Two takeaways: the AWS batch family and the self-hosted workers already attribute a throw to a
specific message; the single-event AWS Lambda sources (S3/EventBridge/Kafka) lean on the platform
host, so wiring UseExceptionHandler/UseLogResult there is what gets you a Benzene-context log
line instead of a bare stack trace in CloudWatch.
Checklist
When a message failed and you're trying to find out why:
- Was it an unsuccessful result or a throw? Look for the router
Warning(result) vs. anErrorwith a stack (throw). - Grep by
traceId(orinvocationId) to pull every line of that one message together. - Check the
topic/handlertags to confirm it routed to the handler you expect — a "No handler found for topic" warning means a routing/registration problem, not a handler bug. - Open the trace if you export spans — the
Error-status span names the failing stage. - Nothing in the logs at all? Confirm a logging provider is configured (not just
AddLogging()), and thatUseLogResult/UseExceptionHandlersit before.UseMessageHandlers().
Further reading
- Monitoring & Diagnostics — configuring logging providers, tracing, W3C context, and OpenTelemetry export
- Global Error Handling —
UseExceptionHandlerbehavior and per-transport response mapping - Common Middleware — reference for
UseBenzeneEnrichment,UseLogResult,UseBenzeneMetrics - Correlation Ids / Request Correlation cookbook — tracking a request across services
- Privacy & Data Handling — keeping PII out of the logs/traces above