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.

Wiring problems are caught before any message arrives

Most of what follows is about diagnosing a message that failed. A whole class of problem is not that at all: the wiring is wrong, and every message will fail the same way for the same reason. Those are checked once, at start-up, before the first message.

Every host runs the checks — AWS Lambda, ASP.NET Core, the generic host, Azure Functions — and so does BenzeneTestHost, which means a wiring mistake is a red unit test rather than something you meet on a deployed function.

Check Behaviour What it catches
duplicate-topic fails start-up Two handlers claiming the same topic and version. Only one can ever run, and which one depends on registration order.
empty-handler-registry logs an error No handlers discovered at all — almost always AddMessageHandlers(...) given the wrong assembly. It logs rather than fails, because a probe- or collector-only service with no handlers is legitimate.
http-routes fails start-up An [HttpEndpoint] handler with no [Message], so discovery skips it and its route never exists. Also compiles the route table at start-up, so the first request no longer pays for it.
outbound-routing fails start-up A topic a generated client will send to with no outbound route registered.
unmapped-response-handlers logs a warning Handlers returning a payload on a topic no response-event mapping covers. Advisory only — a handler that legitimately publishes nothing looks identical.
pipeline-resolution fails start-up Middleware anywhere in any pipeline that cannot be constructed. Middleware is resolved lazily, per link, per message, so without this a pipeline can be completely unable to run and look healthy until traffic arrives. Costs about 16ms on a service the size of examples/Aws, and the work is not wasted — it constructs what the first message would have constructed anyway.
terminal-middleware fails start-up A pipeline with nothing in it that can end — UseSqs(sqs => { }), or a pipeline with logging and validation but no UseMessageHandlers(). It composes, deploys, returns 200, and dead-letters every message it is given. Costs about 9ms on a service the size of examples/Aws.

When terminal-middleware is wrong

The rule is the ITerminalMiddleware marker: a pipeline needs at least one middleware that says it can end one. "Does this middleware call next?" is not answerable without running it, and most middleware answers "sometimes", so the marker is a statement of intent rather than an inference.

Everything in Benzene that ends a pipeline carries it — UseMessageHandlers(), every transport router, every client's send middleware, the health-check endpoints, the spec and mesh UIs. Your own middleware does not, so a pipeline that ends in yours is the one false positive this check can produce. Say so and it stops:

public class ServeFromCache : IMiddleware<SqsMessageContext>, ITerminalMiddleware { ... }

Nothing about execution changes — the marker is read once, at start-up. For inline middleware, where there is no type to mark, UseTerminal(name, ...) is the same statement:

app.UseTerminal("static-response", async (context, next) => { ... });

If a check is wrong for your application, one switch covers all of them:

services.UsingBenzene(x => x
    .AddBenzene()
    .AddBenzeneStartUpChecks(BenzeneStartUpCheckMode.Advisory)   // log and continue
    .AddMessageHandlers(typeof(Program).Assembly));

BenzeneStartUpCheckMode.Disabled turns them off entirely. There is deliberately one switch rather than one per check: an escape hatch you have to go looking for is not an escape hatch.

Two related checks are worth knowing about but are not part of this phase:

Infrastructure failures are not the message's fault

There is a third kind of failure that is neither of the two below, and it needs the opposite handling: the service is mis-wired. A registration is missing, so every message fails the same way, and no amount of retrying or redelivery will change that.

Reported per message, that is a slow disaster. Each record is dead-lettered on its own, the next one does exactly the same, and the function keeps returning success — so the queue drains into the DLQ while the service reports itself healthy.

Benzene now tells the two apart. A resolution failure throws BenzeneResolutionException, and BenzeneFailure.IsInfrastructure(exception) recognises it anywhere in the exception chain:

The classification is deliberately narrow: only a proven resolution failure counts, because the cost of a false positive is failing a whole batch over one bad message. Anything unrecognised is treated as a business failure — the existing, safe behaviour.

Most wiring faults never get this far, because the pipeline-resolution start-up check above constructs every middleware before any message arrives. What reaches here is the residual: a handler's own dependency, which is only constructed when a message is dispatched to its topic.

Two kinds of failure

Benzene distinguishes between two failure modes, and they surface differently:

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(...) calls AddLogging() so ILogger<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.

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 not-found
    { 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:

Ordering footguns

A few middleware silently do nothing if wired in the wrong order — no error, just missing data:

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 YesError "Processing SQS message failed"
AWS Lambda SNS (SnsApplication) catch when CatchExceptions YesError (when catching)
AWS Lambda DynamoDb / Kinesis catch → checkpoint/stop YesError 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 YesError per message
Self-hosted Azure Service Bus (BenzeneServiceBusWorker) catch → abandon + rethrow YesError 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:

  1. Was it an unsuccessful result or a throw? Look for the router Warning (result) vs. an Error with a stack (throw).
  2. Grep by traceId (or invocationId) to pull every line of that one message together.
  3. Check the topic/handler tags 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.
  4. Open the trace if you export spans — the Error-status span names the failing stage.
  5. Nothing in the logs at all? Confirm a logging provider is configured (not just AddLogging()), and that UseLogResult/UseExceptionHandler sit before .UseMessageHandlers().

Further reading