Skip to main content

Observability

OpinionatedEventing emits structured logs, distributed traces, and metrics out of the box. All telemetry uses standard .NET abstractions — no Serilog, Seq, or OpenTelemetry package is required in the library itself.

Logging

The library uses Microsoft.Extensions.Logging.ILogger<T>. As long as you configure a logging provider in your host, log output flows automatically.

What is logged

ComponentLevelEvents
OutboxDispatcherWorkerDebugPoll cycle start/end, batch size
OutboxDispatcherWorkerInformationMessage dispatched successfully
OutboxDispatcherWorkerWarningDispatch attempt failed (retrying)
OutboxDispatcherWorkerErrorMessage dead-lettered after MaxAttempts
SagaTimeoutWorkerDebugTimeout check cycle
SagaTimeoutWorkerInformationSaga timed out, timeout handler invoked
SagaTimeoutWorkerErrorTimeout handler threw exception
Transport consumersDebugMessage received
Transport consumersInformationMessage handled successfully
Transport consumersErrorHandler threw unhandled exception

Configuring log levels

In appsettings.json:

{
"Logging": {
"LogLevel": {
"Default": "Information",
"OpinionatedEventing": "Debug"
}
}
}

Distributed tracing

The library uses System.Diagnostics.ActivitySource. Wire up spans via the OpenTelemetry SDK:

using OpinionatedEventing.OpenTelemetry;

services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddOpinionatedEventingInstrumentation()
.AddOtlpExporter());

Emitted spans

Span nameWhenKey attributes
outbox.writeIPublisher writes to the outbox storemessaging.message.type, messaging.message.kind, messaging.message.correlation_id
outbox.dispatchOutboxDispatcherWorker calls ITransport.SendAsyncmessaging.message.id, messaging.message.type
consumeTransport consumer hands off to handler(s)messaging.message.type, messaging.message.kind, messaging.message.correlation_id
saga.stepA saga handler executessaga.type, saga.correlation_key, messaging.message.type

Each span carries W3C trace context propagated through the message envelope so traces span service boundaries.

When using Aspire, the dashboard includes a built-in trace viewer. Traces from all services are aggregated automatically — no additional exporter configuration is needed for local development.

Metrics

Wire up the metrics provider:

using OpinionatedEventing.OpenTelemetry;

services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.AddOpinionatedEventingMetrics()
.AddOtlpExporter());

Available instruments

InstrumentTypeDescription
opinionatedeventing.outbox.pendingGaugeCurrent number of pending (unprocessed) outbox messages
opinionatedeventing.outbox.processedCounterTotal messages successfully dispatched to the broker
opinionatedeventing.outbox.failedCounterTotal messages dead-lettered after MaxAttempts
opinionatedeventing.publish.durationHistogramTime (ms) to write a message to the outbox store
opinionatedeventing.dispatch.durationHistogramTime (ms) to dispatch a message to the broker
opinionatedeventing.consume.durationHistogramTime (ms) for the handler to process a message
opinionatedeventing.saga.activeGaugeCurrent number of active saga instances
opinionatedeventing.saga.timed_outCounterTotal sagas that have timed out

Alerting recommendations

MetricAlert conditionMeaning
opinionatedeventing.outbox.pending> threshold for > 5 minDispatcher is stalled or the broker is unavailable
opinionatedeventing.outbox.failedRate > 0Messages are dead-lettering — investigate errors
opinionatedeventing.consume.durationp99 > SLAHandler is slow — risk of broker timeout and redelivery
opinionatedeventing.saga.activeGrowing without boundSagas are not completing — check timeouts

Health checks

services.AddHealthChecks()
.AddOpinionatedEventingHealthChecks(options =>
{
options.OutboxBacklogThreshold = 100;
options.SagaTimeoutBacklogThreshold = 10;
});

app.MapHealthChecks("/health");
app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = c => c.Tags.Contains("live") });
app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = c => c.Tags.Contains("ready") });
TagChecks
liveBroker connectivity
readyOutbox backlog, saga timeout backlog

To automatically pause consumers when a dependency becomes unavailable:

services.AddHealthChecks()
.AddOpinionatedEventingHealthChecks()
.AddNpgsql(connectionString, tags: ["pause"])
.WithConsumerPause();

Correlation and causation IDs

Every message carries two identifiers:

  • CorrelationId — Set at the entry point and propagated through every event and command in the chain.
  • CausationId — The Id of the message that caused this one to be sent.

These IDs are accessible in handlers via IMessagingContext:

public class OrderNotificationHandler(IMessagingContext context) : IEventHandler<OrderPlaced>
{
public Task HandleAsync(OrderPlaced @event, CancellationToken ct)
{
// context.CorrelationId — same across the entire order flow
// context.CausationId — ID of the message that triggered this handler
return Task.CompletedTask;
}
}

Automatic logging scope

MessageHandlerRunner calls ILogger.BeginScope before invoking handlers, pushing CorrelationId, CausationId, and MessageType as structured properties into the ambient logging scope:

{
"CorrelationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"CausationId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"MessageType": "MyApp.Orders.OrderPlaced, MyApp"
}

Any ILogger<T> used inside a handler automatically inherits these properties.