Skip to content

Repository files navigation

SimplyWorks.Bus

Build and Publish NuGet PackageNuGetNuGetNuGetLicense: MIT

A lightweight .NET 8 message bus library built on top of RabbitMQ, designed for event-driven microservice architectures in ASP.NET Core.

The library ships as three complementary NuGet packages:

PackagePurpose
SimplyWorks.BusCore runtime — publishing, consuming, retries, dead-letter, tracing
SimplyWorks.Bus.RabbitMqExtensionsPublic contracts, consumer interfaces, dashboard data models
SimplyWorks.Bus.RabbitMqViewerBuilt-in HTMX operations dashboard (dark + light mode, zero JS framework)

Table of Contents

  1. Features
  2. Installation
  3. Quick Start
  4. Publishing Messages
  5. Consuming Messages
  6. Delayed Publishing
  7. Broadcasting & Listeners
  8. Per-Queue Configuration
  9. Extended Consumer Options (IConsumeExtended)
  10. Monitoring — IConsumerReader
  11. Error Queue Inspection — IErrorQueueReader
  12. Operational Events Pipeline
  13. Dashboard Data Service — IBusDashboardDataService
  14. Custom Alert Thresholds — IAlertEvaluator
  15. Building a Custom Dashboard
  16. Operations Viewer Dashboard
  17. Full BusOptions Reference
  18. Architecture
  19. Testing
  20. Dependencies

Features

  • 🚌 Simple Message Publishing — typed and string-based IPublish
  • ⏱️ Delayed Publishing — deliver to a single consumer after an exact delay via IDelayedPublish; uses the RabbitMQ Delayed Message Exchange plugin when available, TTL buckets otherwise
  • 📡 Broadcasting — fan-out to all application instances via IBroadcast / IListen<T>
  • 🔄 Automatic Retries — configurable per-queue retry counts and delay with dead-letter routing
  • 🎯 Typed ConsumersIConsume<T> with strongly-typed message deserialization
  • ⚙️ Extended Consumer Options — per-consumer prefetch and priority via IConsumeExtended
  • 🔐 JWT Propagation — user context forwarded through message headers across services
  • 📊 Queue Monitoring — live queue depth, rates, and consumer counts via IConsumerReader
  • 🔍 Error Queue Inspection — peek retry and dead-letter queues via IErrorQueueReader
  • 📈 Operational Events — structured lifecycle events with ActivitySource tracing and Meter metrics
  • 🖥️ Built-in Dashboard — HTMX admin UI with dark/light mode toggle, form-based login, CSS refresh animations, and critical status tooltips via SimplyWorks.Bus.RabbitMqViewer
  • 🏗️ Custom Dashboard API — all data exposed through IBusDashboardDataService in SimplyWorks.Bus.RabbitMqExtensions — build your own UI without the viewer package
  • 🧪 Testing Support — mock publisher for unit testing

Installation

# Core — always required
dotnet add package SimplyWorks.Bus
# Public contracts + monitoring interfaces (no RabbitMQ.Client dependency)
dotnet add package SimplyWorks.Bus.RabbitMqExtensions
# Optional: built-in operations dashboard
dotnet add package SimplyWorks.Bus.RabbitMqViewer

Quick Start

1. Connection string

Add RabbitMQ connection string to your appsettings.json:

{
"ConnectionStrings": {
"RabbitMQ": "amqp://guest:guest@localhost:5672/"
}
}

2. Service registration

In your Startup.cs or Program.cs:

services.AddBus(config =>{config.ApplicationName="MyApp";// Optional JWT configurationconfig.Token.Key=Configuration["Token:Key"];config.Token.Issuer=Configuration["Token:Issuer"];config.Token.Audience=Configuration["Token:Audience"];});services.AddBusPublish();// registers IPublish, IBroadcastservices.AddBusConsume();// registers IHostedService consumer + scans calling assembly

Publishing Messages

publicclassOrderController:ControllerBase{privatereadonlyIPublish_publish;privatereadonlyIBroadcast_broadcast;publicOrderController(IPublishpublish,IBroadcastbroadcast){_publish=publish;_broadcast=broadcast;}[HttpPost]publicasyncTask<IActionResult>Create(CreateOrderRequestreq){// Routed to consumers subscribed to OrderCreatedawait_publish.Publish(newOrderCreated{OrderId=Guid.NewGuid()});// Fan-out to all connected application instancesawait_broadcast.Broadcast(newOrderNotification{Message="New order"});returnOk();}}

String-based publish (useful for dynamic routing):

await_publish.Publish("OrderCreated",jsonPayload);await_publish.Publish("OrderCreated",payloadBytes);

Consuming Messages

Typed consumer

publicclassOrderCreatedConsumer:IConsume<OrderCreated>{publicasyncTaskProcess(OrderCreatedmessage){// Auto-acked on success, rejected/retried on exception}}

Multi-message string consumer

publicclassGenericConsumer:IConsume{publicTask<IEnumerable<string>>GetMessageTypeNames()=>Task.FromResult<IEnumerable<string>>(new[]{"OrderCreated","OrderCancelled"});publicasyncTaskProcess(stringtypeName,stringmessage){ ...}}

Optional failure handler

publicclassOrderCreatedConsumer:IConsume<OrderCreated>{publicasyncTaskProcess(OrderCreatedmessage){ ...}// Called on every failure (including retries) — optionalpublicasyncTaskOnFail(Exceptionex){ ...}}

Accessing request context

publicclassSecureConsumer:IConsume<SecureMessage>{privatereadonlyRequestContext_ctx;publicSecureConsumer(RequestContextctx)=>_ctx=ctx;publicasyncTaskProcess(SecureMessagemsg){varuser=_ctx.User;varcorrelationId=_ctx.CorrelationId;varremaining=_ctx.GetValue("RemainingRetries");}}

Consumer registration

services.AddBusConsume();// scan calling assemblyservices.AddBusConsume(typeof(OrderCreatedConsumer).Assembly);// specific assembly

Delayed Publishing

IDelayedPublish delivers a message to one specific consumer queue after a delay. Unlike IPublish — which routes by message type and fans out to every consumer bound to that type — delayed publishing routes by the target consumer's class name, so only that one queue receives the message.

IDelayedPublish is registered automatically when you call AddBusPublish().

Usage

publicclassOrderController:ControllerBase{privatereadonlyIDelayedPublish_delayed;publicOrderController(IDelayedPublishdelayed)=>_delayed=delayed;[HttpPost("schedule")]publicasyncTask<IActionResult>Schedule(){// Deliver to OrderReminderConsumer.Process(OrderReminder) in 30 minutesawait_delayed.PublishDelayed(newOrderReminder{OrderId="123"},consumerName:nameof(OrderReminderConsumer),delay:TimeSpan.FromMinutes(30));returnAccepted();}}

The consumerName is the exact class name of the target consumer. Combined with the message type name, it forms the naked queue name {ConsumerClass}.{MessageType} that uniquely identifies the consumer queue.

// Raw overload — useful when consumer and message types are not referenced in the calling assemblyawait_delayed.PublishDelayed(messageTypeName:"OrderReminder",body:jsonString,nakedQueueName:"orderreminderconsumer.orderreminder",delay:TimeSpan.FromMinutes(30));

Passing TimeSpan.Zero (or negative) skips the delay entirely and delivers immediately to the targeted consumer.

Why not IPublish with a delay?

IPublish.Publish routes by message type name on the process exchange. Every consumer queue bound to that message type receives a copy — that is intentional fan-out. IDelayedPublish routes by the consumer's naked queue name on a separate direct exchange, so the message reaches exactly one queue regardless of how many other consumers handle the same message type.

How delivery works

The library detects at startup whether the broker has the RabbitMQ Delayed Message Exchange plugin enabled, and picks the appropriate strategy automatically. You write the same calling code either way.

Strategy 1 — Delayed Message Exchange plugin (exact delays)

When the plugin is available (BusOptions.DelayedPluginAvailable == true), the library declares an x-delayed-message exchange (v3.{env}.delay.x) and publishes with an x-delay header set to the requested milliseconds. The plugin holds the message broker-side until the delay elapses, then routes it directly to the target consumer queue.

Publisher ──► v3.{env}.delay.x (x-delayed-message, direct)
│ holds for exactly N ms
▼
v3.{env}.{app}.{ConsumerClass}.{MessageType} ← target consumer queue

This is the preferred path — delays are precise to the millisecond.

Strategy 2 — TTL delay buckets (fallback when plugin is absent)

When the plugin is not installed, the library uses RabbitMQ's native TTL + dead-letter mechanism. Requested delays are rounded up to the nearest pre-configured bucket (default ladder: 1 s, 5 s, 15 s, 30 s, 60 s, 5 min, 15 min, 30 min, 1 h). A pair of exchanges and a queue are created on demand for each bucket duration used.

Publisher
│ publish(routingKey = "orderreminderconsumer.orderreminder")
▼
v3.{env}.delay.in.1800s ← fanout entry exchange
│ (delivers to TTL queue regardless of routing key,
│ but preserves the original routing key on the message)
▼
v3.{env}.delay.1800s ← TTL queue (x-message-ttl = 1800000 ms)
│
│ TTL expires → dead-letter with original routing key intact
▼
v3.{env}.delay ← direct router exchange
│ routes by "orderreminderconsumer.orderreminder"
▼
v3.{env}.{app}.orderreminderconsumer.orderreminder ← target consumer queue

Why the fanout entry exchange?
Publishing directly into the TTL queue (via the default exchange) would replace the routing key with the queue name, losing the consumer target. The fanout entry exchange delivers to the TTL queue without touching the routing key, so it survives intact all the way to the dead-letter route on the direct router.

Bucket queues are declared lazily on first use. Each bucket is created at most once per process lifetime. The bucket ladder is configurable:

services.AddBus(config =>{// Custom bucket ladder — delays round up to nearest value (seconds)config.DelayBucketsSeconds=new[]{5,30,60,300,3600};});

A delay larger than all configured buckets creates a one-off bucket for that exact duration.

Exchange topology summary

ExchangeTypePurpose
v3.{env}.delayDirectFinal router — consumer queues bind here by naked queue name
v3.{env}.delay.xx-delayed-messagePlugin path — holds messages until delay elapses
v3.{env}.delay.in.{N}sFanoutTTL fallback — entry point for each bucket duration

Each consumer queue gets an extra binding to v3.{env}.delay (and v3.{env}.delay.x when the plugin is present) during ConsumersService startup. No other topology changes are needed.

Failure handling after delivery

Once a delayed message is delivered to the consumer queue, the normal retry and dead-letter machinery takes over. If the consumer throws, the message is rejected to its .retry queue, retried up to DefaultRetryCount times, and moved to .bad on exhaustion — exactly the same as a non-delayed message.


Broadcasting & Listeners

Broadcasts are fan-out messages delivered to every running instance simultaneously.

// Sendawait_broadcast.Broadcast(newPricingUpdated{Version=42});// Trigger live consumer refresh across all instancesawait_broadcast.RefreshConsumers();
// ReceivepublicclassPricingUpdatedListener:IListen<PricingUpdated>{publicasyncTaskProcess(PricingUpdatedmessage){ ...}publicasyncTaskOnFail(Exceptionex){ ...}// optional}services.AddBusListen();// scan calling assemblyservices.AddBusListen(typeof(PricingUpdatedListener).Assembly);// specific assembly

Per-Queue Configuration

Fine-tune individual queues via BusOptions.AddQueueOption. The key is "{ConsumerClass}.{MessageType}" (case-insensitive).

services.AddBus(config =>{config.AddQueueOption("OrderCreatedConsumer.OrderCreated",prefetch:10,retryCount:3,retryAfterSeconds:30);// Enable priority queue (0–10 range)config.AddQueueOption("PaymentConsumer.PaymentProcessed",priority:10);});

Extended Consumer Options (IConsumeExtended)

For consumers that need per-instance prefetch or priority without touching global config:

// Typed consumer with runtime optionspublicclassPriorityOrderConsumer:IConsumeExtended<OrderCreated>{publicasyncTaskProcess(OrderCreatedmessage){ ...}publicTask<ConsumerOptions>GetConsumerOptions()=>Task.FromResult(newConsumerOptions{Prefetch=8,Priority=5});}// Multi-message consumer with per-type optionspublicclassMultiConsumer:IConsumeExtended{publicTask<IEnumerable<string>>GetMessageTypeNames()=>Task.FromResult<IEnumerable<string>>(new[]{"TypeA","TypeB"});publicTask<IDictionary<string,ConsumerOptions>>GetMessageTypeNamesWithOptions()=>Task.FromResult<IDictionary<string,ConsumerOptions>>(newDictionary<string,ConsumerOptions>{["TypeA"]=newConsumerOptions{Prefetch=4},["TypeB"]=newConsumerOptions{Prefetch=16,Priority=3}});publicasyncTaskProcess(stringtypeName,stringmessage){ ...}}

IConsumeExtended consumers are hot-reloadable — call IBroadcast.RefreshConsumers() to apply updated options across all running instances without restart.


Monitoring — IConsumerReader

Registered automatically by AddBus(). Returns live queue statistics from the RabbitMQ Management API with configurable caching.

// All consumersvarall=await_reader.GetAllConsumersCount();// Typed consumer + message typevarorder=await_reader.GetConsumerCount<OrderConsumer,OrderCreated>();// Multi-message consumer by message namevargeneric=await_reader.GetConsumerCount<GenericConsumer>("OrderCreated");// All queues for a consumer classvarbyClass=await_reader.GetConsumerCount<OrderConsumer>();

ConsumerCount fields:

FieldDescription
NameConsumer class name
MessageNameMessage type name
TotalNodesActive consumer instances
ProcessingCountMessages in-flight (unacknowledged)
QueueCountMessages ready in main queue
RetryCountMessages in retry queue
FailedCountMessages in dead-letter queue
PriorityConsumer priority level
PrefetchQoS prefetch count
IncomingRatePublish rate (msg/s)
ProcessingRateDeliver rate to consumers (msg/s)
AckRateAcknowledge rate (msg/s)
services.AddBus(config =>{config.MonitoringCacheSeconds=10;// cache duration 3–60 s, default 5config.ManagementUrl="http://localhost:15672";// defaults from AMQP URIconfig.ManagementUsername="guest";config.ManagementPassword="guest";config.VirtualHost="/";});

Error Queue Inspection — IErrorQueueReader

Peek at messages in retry or dead-letter queues without removing them.

// Typed consumervarfailed=await_errorReader.Peek<OrderConsumer,OrderCreated>(ErrorQueueType.Bad,count:20);// Multi-message consumervarretrying=await_errorReader.Peek<GenericConsumer>("OrderCreated",ErrorQueueType.Retry);// Raw queue namevarraw=await_errorReader.PeekByQueueName("v3.production.orderconsumer.ordercreated.bad");

ErrorMessage properties:

PropertyDescription
RawBodyOriginal JSON payload
ExchangeExchange where message was published
RoutingKeyRouting key used at publish
PropertiesAll AMQP properties
HeadersExtracted AMQP headers
CorrelationIdCorrelation ID if set
ExceptionHistoryAll recorded exceptions oldest-first
LastExceptionMost recent exception string

Operational Events Pipeline

SW.Bus emits strongly-typed lifecycle events that flow through a lock-free buffered pipeline. The goal is observability and diagnostics, not logging.

Events emitted

EventTrigger
PublishStartedMessage about to be published
PublishCompletedPublish succeeded (includes duration ms, payload bytes)
PublishFailedPublish threw an exception
MessageProcessingStartedConsumer received and started processing
MessageProcessingCompletedProcessed successfully (includes duration ms)
MessageProcessingFailedConsumer threw an exception (type, message, stack trace)
MessageRetryScheduledMessage rejected back to retry queue
MessageMovedToDeadLetterMessage exhausted retries
ConsumerConnectedConsumer channel attached to a queue
ConsumerDisconnectedConsumer channel shut down
QueueBackpressureDetectedQueue depth exceeded QueueBackpressureThreshold

Every event carries: TimestampUtc, SchemaVersion, EventName, MachineName, Environment, ApplicationName, Exchange, QueueName, ConsumerName, MessageType, MessageId, CorrelationId, CausationId, TraceId, SpanId, DeliveryTag.

Pipeline architecture

Consumer / Publisher hot path
│ (non-blocking TryWrite)
▼
BoundedChannel<IOperationalEvent> ← configurable capacity, drop-oldest on full
│
▼ (BackgroundService, configurable flush interval)
OperationalEventDispatcher
├── InMemoryOperationalEventStore (ring buffer — always present)
└── [your IOperationalEventBatchSink registrations]

Plugging in a custom external sink

usingSW.Bus.RabbitMqExtensions;publicclassElasticsearchSink:IOperationalEventBatchSink{publicasyncTaskPublishBatch(IReadOnlyList<IOperationalEvent>events,CancellationTokencancellationToken=default){await_esClient.BulkAsync(events,cancellationToken);}}// Sinks stack additively — InMemoryStore is always active alongside yoursservices.AddSingleton<IOperationalEventBatchSink,ElasticsearchSink>();

OpenTelemetry tracing

services.AddOpenTelemetry().WithTracing(b =>b.AddSource("SimplyWorks.Bus")// ActivitySource name.AddOtlpExporter());

Prometheus / OpenTelemetry metrics (meter name: "SimplyWorks.Bus")

services.AddOpenTelemetry().WithMetrics(b =>b.AddMeter("SimplyWorks.Bus").AddPrometheusExporter());
InstrumentTypeDescription
sw_bus_publish_started_totalCounterPublish attempts
sw_bus_publish_completed_totalCounterSuccessful publishes
sw_bus_publish_failed_totalCounterFailed publishes
sw_bus_processing_started_totalCounterMessages picked up
sw_bus_processing_completed_totalCounterMessages processed successfully
sw_bus_processing_failed_totalCounterMessages that threw exceptions
sw_bus_retry_scheduled_totalCounterMessages sent to retry queue
sw_bus_dead_letter_totalCounterMessages moved to dead-letter
sw_bus_operational_event_dropped_totalCounterEvents dropped (buffer full)
sw_bus_processing_latency_msHistogramConsumer processing time
sw_bus_publish_latency_msHistogramPublish time

Pipeline configuration

services.AddBus(config =>{config.OperationalEventsEnabled=true;// defaultconfig.OperationalEventsBufferCapacity=8192;// channel buffer before dropconfig.OperationalEventsBatchSize=256;// events per flush batchconfig.OperationalEventsFlushIntervalMs=1000;// ms between flushesconfig.OperationalEventsDropOldest=true;// drop strategy when fullconfig.OperationalEventsSchemaVersion="1.0";// stamped on every eventconfig.OperationalEventsStoreCapacity=10000;// in-memory ring buffer size});

Dashboard Data Service — IBusDashboardDataService

All dashboard data is exposed through IBusDashboardDataService (defined in SimplyWorks.Bus.RabbitMqExtensions, implemented and registered by SimplyWorks.Bus). Inject it directly to build your own custom dashboard, REST API, or health probe — no dependency on SimplyWorks.Bus.RabbitMqViewer needed. See Building a Custom Dashboard for a complete guide.

publicclassOpsController:ControllerBase{privatereadonlyIBusDashboardDataService_dash;publicOpsController(IBusDashboardDataServicedash)=>_dash=dash;[HttpGet("ops/summary")]publicasyncTask<IActionResult>Summary()=>Ok(await_dash.GetSummaryAsync());[HttpGet("ops/consumers")]publicasyncTask<IActionResult>Consumers()=>Ok(await_dash.GetConsumerHealthAsync());[HttpGet("ops/queues")]publicasyncTask<IActionResult>Queues()=>Ok(await_dash.GetQueueDetailsAsync());[HttpGet("ops/retries")]publicasyncTask<IActionResult>Retries()=>Ok(await_dash.GetRetryAnalysisAsync());[HttpGet("ops/dead-letters")]publicasyncTask<IActionResult>DeadLetters()=>Ok(await_dash.GetDeadLetterSummaryAsync());[HttpGet("ops/alerts")]publicasyncTask<IActionResult>Alerts()=>Ok(await_dash.GetAlertsAsync());[HttpGet("ops/events")]publicIActionResultEvents([FromQuery]string?consumer,[FromQuery]string?messageType,[FromQuery]string?correlationId,[FromQuery]string?traceId,[FromQuery]string?eventName,[FromQuery]intlimit=200)=>Ok(_dash.GetRecentEvents(newOperationalEventFilter(ConsumerName:consumer,MessageType:messageType,CorrelationId:correlationId,TraceId:traceId,EventName:eventName,Limit:limit)));}

View models:

RecordKey fields
DashboardSummaryTotalConsumers, UnhealthyConsumers, DisconnectedConsumers, TotalQueueDepth, TotalRetryBacklog, TotalDeadLetterBacklog, TotalIncomingRate, TotalAckRate, ActiveAlerts, LastUpdatedUtc
ConsumerHealthViewAll ConsumerCount fields + QueueName, IsBackpressured, HealthStatus (AlertSeverity)
QueueDetailViewMain/retry/dead-letter queue names and depths, consumer count, rates
RetryAnalysisViewPer-consumer retry backlog ordered by size with severity
DeadLetterSummaryViewPer-consumer DL count, last exception type/message, last failure timestamp
DashboardAlertSeverity (Info/Warning/Critical), Title, Detail, QueueName, ConsumerName, TimestampUtc

OperationalEventFilter fields (all optional, string fields are case-insensitive substring matches):

ApplicationName, ConsumerName, MessageType, CorrelationId, TraceId, QueueName, EventName (exact), From, To, Limit (default 200)


Custom Alert Thresholds — IAlertEvaluator

Default thresholds are configured on BusOptions:

services.AddBus(config =>{config.AlertRetryWarningThreshold=10;// Warning when retry backlog ≥ Nconfig.AlertRetryCriticalThreshold=100;// Critical when retry backlog ≥ Nconfig.AlertDeadLetterCriticalThreshold=100;// Critical when DL count ≥ Nconfig.QueueBackpressureThreshold=5000;// backpressure warning threshold});

To apply domain-specific or SLA-based thresholds, replace the default evaluator:

publicclassMyAlertEvaluator:IAlertEvaluator{publicIReadOnlyList<DashboardAlert>Evaluate(ConsumerHealthView[]consumers){varalerts=newList<DashboardAlert>();foreach(varcinconsumers){if(c.Name=="PaymentConsumer"&&c.FailedCount>0)alerts.Add(newDashboardAlert(AlertSeverity.Critical,"Payment Dead Letter",$"{c.FailedCount} failed payments require immediate attention.",c.QueueName,c.Name,DateTime.UtcNow));}returnalerts;}}// Register before AddBus() — TryAddSingleton means yours winsservices.AddSingleton<IAlertEvaluator,MyAlertEvaluator>();services.AddBus(...);

Building a Custom Dashboard

If you do not want to use SimplyWorks.Bus.RabbitMqViewer — e.g. you want React, Blazor, an existing admin framework, or a JSON API for a mobile app — everything you need is in SimplyWorks.Bus.RabbitMqExtensions. You never need to install the viewer package.

Package requirements

dotnet add package SimplyWorks.Bus # core runtime (always required)
dotnet add package SimplyWorks.Bus.RabbitMqExtensions # contracts + data service# SimplyWorks.Bus.RabbitMqViewer is NOT needed

What the library provides

All interfaces below are registered automatically by services.AddBus(...). Just inject what you need.

InterfaceDescription
IBusDashboardDataServiceAggregate read layer — summary, consumer health, queues, retries, dead letters, alerts, events
IConsumerReaderRaw per-consumer queue statistics from the RabbitMQ Management API (with configurable caching)
IErrorQueueReaderPeek at messages in retry and dead-letter queues without removing them
IOperationalEventStoreQuery the in-memory event ring buffer with OperationalEventFilter
IAlertEvaluatorEvaluate a ConsumerHealthView[] snapshot and return DashboardAlert objects
IOperationalEventBatchSinkImplement to stream event batches to external systems (Elasticsearch, ClickHouse, etc.)

Minimal REST API example (no viewer package)

// Program.csbuilder.Services.AddBus(config =>{config.ApplicationName="MyApp";/* ... */});builder.Services.AddBusPublish();builder.Services.AddBusConsume();varapp=builder.Build();app.MapGet("/ops/summary",async(IBusDashboardDataServiced)=>awaitd.GetSummaryAsync());app.MapGet("/ops/consumers",async(IBusDashboardDataServiced)=>awaitd.GetConsumerHealthAsync());app.MapGet("/ops/queues",async(IBusDashboardDataServiced)=>awaitd.GetQueueDetailsAsync());app.MapGet("/ops/retries",async(IBusDashboardDataServiced)=>awaitd.GetRetryAnalysisAsync());app.MapGet("/ops/dead-letters",async(IBusDashboardDataServiced)=>awaitd.GetDeadLetterSummaryAsync());app.MapGet("/ops/alerts",async(IBusDashboardDataServiced)=>awaitd.GetAlertsAsync());app.MapGet("/ops/events",(IBusDashboardDataServiced,string?consumer,string?messageType,string?eventName,intlimit=100)=>d.GetRecentEvents(newOperationalEventFilter(ConsumerName:consumer,MessageType:messageType,EventName:eventName,Limit:limit)));app.Run();

Querying the operational event store directly

publicclassMyOpsService{privatereadonlyIOperationalEventStore_store;publicMyOpsService(IOperationalEventStorestore)=>_store=store;publicIReadOnlyList<IOperationalEvent>GetRecentFailures(intlimit=50)=>_store.GetRecent(newOperationalEventFilter(EventName:"MessageProcessingFailed",Limit:limit));publiclongTotalEventsSinceStartup=>_store.TotalReceived;}

Pattern matching on event records

All events are strongly typed C# records inheriting from OperationalEventBase. Use pattern matching to extract type-specific fields:

foreach(varevtin_store.GetRecent()){switch(evt){caseMessageProcessingFailedf:Console.WriteLine($"[FAIL] {f.ConsumerName}{f.ExceptionType}: {f.ExceptionMessage}");break;caseMessageProcessingCompletedc:Console.WriteLine($"[OK] {c.ConsumerName} in {c.ProcessingDurationMs:F1} ms");break;caseMessageRetryScheduledr:Console.WriteLine($"[RETRY] {r.ConsumerName} attempt {r.RetryCount}, {r.RemainingRetryCount} remaining");break;caseMessageMovedToDeadLetterdl:Console.WriteLine($"[DLQ] {dl.ConsumerName}{dl.DeadLetterRoutingKey}");break;caseQueueBackpressureDetectedbp:Console.WriteLine($"[PRESSURE] {bp.QueueName} depth={bp.QueueDepth} threshold={bp.Threshold}");break;caseConsumerConnectedcc:Console.WriteLine($"[CONNECT] {cc.ConsumerName} tag={cc.ConsumerTag}");break;caseConsumerDisconnectedcd:Console.WriteLine($"[DISCONN] {cd.ConsumerName} reason={cd.Reason}");break;casePublishFailedpf:Console.WriteLine($"[PUB FAIL] {pf.MessageType}{pf.ExceptionType}");break;}}

Replacing or extending the in-memory event store

Register IOperationalEventBatchSink to stream events alongside the built-in ring buffer:

publicclassElasticsearchSink:IOperationalEventBatchSink{publicasyncTaskPublishBatch(IReadOnlyList<IOperationalEvent>events,CancellationTokencancellationToken=default)=>await_esClient.BulkAsync(events,cancellationToken);}services.AddSingleton<IOperationalEventBatchSink,ElasticsearchSink>();services.AddBus(...);// in-memory store and your sink both active

To replace query access with your own persistent store:

publicclassClickHouseEventStore:IOperationalEventStore,IOperationalEventBatchSink{publiclongTotalReceived=>/* query row count */;publicIReadOnlyList<IOperationalEvent>GetRecent(OperationalEventFilter?filter=null)=>/* translate filter → SQL → query */;publicasyncTaskPublishBatch(IReadOnlyList<IOperationalEvent>events,CancellationTokenct=default)=>await_clickHouse.BulkInsertAsync(events,ct);}// Register BEFORE AddBus() — TryAddSingleton means yours winsservices.AddSingleton<ClickHouseEventStore>();services.AddSingleton<IOperationalEventStore>(sp =>sp.GetRequiredService<ClickHouseEventStore>());services.AddSingleton<IOperationalEventBatchSink>(sp =>sp.GetRequiredService<ClickHouseEventStore>());services.AddBus(...);

Health probe using ConsumerHealthView.HealthStatus

builder.Services.AddHealthChecks().AddAsyncCheck("bus-consumers",async(IBusDashboardDataServicedash,ct)=>{varconsumers=awaitdash.GetConsumerHealthAsync(ct);vardisconnected=consumers.Where(c =>c.TotalNodes==0).ToList();varcritical=consumers.Where(c =>c.HealthStatus==AlertSeverity.Critical).ToList();if(disconnected.Any())returnHealthCheckResult.Unhealthy($"{disconnected.Count} consumer(s) disconnected: "+string.Join(", ",disconnected.Select(c =>c.Name)));if(critical.Any())returnHealthCheckResult.Degraded($"{critical.Count} consumer(s) in critical state.");returnHealthCheckResult.Healthy($"{consumers.Length} consumer(s) all healthy.");});

Operations Viewer Dashboard

SimplyWorks.Bus.RabbitMqViewer adds a server-rendered operations dashboard built with Pico.css + HTMX. No JavaScript framework required. All tables auto-refresh via HTMX polling with CSS animations.

dotnet add package SimplyWorks.Bus.RabbitMqViewer

Pages

RouteContent
/bus-viewerOverview — summary cards, consumer health table, active alerts, live event feed
/bus-viewer/consumersConsumer Health — full grid with status badges, rates, prefetch, priority
/bus-viewer/queuesQueue Details — main + retry + dead-letter depths and rates per queue
/bus-viewer/retriesRetry Analysis — consumers with retry backlogs ordered by severity
/bus-viewer/dead-lettersDead-Letter Inspection — counts, last exception, last failure timestamp
/bus-viewer/eventsLive Events — filterable operational event stream with inline exception details
/bus-viewer/loginLogin page — form-based credential entry (no browser credential caching)
/bus-viewer/logoutLogout — clears session cookie and redirects to login

UI features

  • 🌗 Dark / Light mode toggle — persisted in localStorage; theme restored before first paint to prevent flash
  • ⚠️Critical status tooltips — hovering a "⚠ Critical" badge shows a bullet list of exactly why the consumer is critical (disconnected nodes, dead-letter count, retry count, backpressure, publish/ack imbalance)
  • Live refresh animations — a blue-purple shimmer bar sweeps the top edge of each table zone while loading; each <tbody> row slides up and fades in with a 60 ms stagger on data arrival
  • 🔴 Pulsing live-data dot — a green dot next to "Updated HH:mm:ss" confirms live polling is active
  • Data auto-refreshes: consumer/queue/retry tables every 10 s, events every 5 s, dead-letters every 15 s

Registration

// Program.cs / Startup.ConfigureServices — call after AddBus()builder.Services.AddBusViewer(o =>o.UseBasicAuth());
// Middleware pipelineapp.UseStaticFiles();// serves /_content/SimplyWorks.Bus.RabbitMqViewer/bus-viewer.cssapp.UseRouting();app.UseAuthentication();// required for RequirePolicy() modeapp.UseAuthorization();app.UseBusViewer();app.MapRazorPages();// discovers BusViewer area automatically

Note:UseBusViewer() can be placed at any position in the pipeline. When UseBasicAuth() is active, an IStartupFilter automatically inserts the viewer's authentication gate at the very beginning of the pipeline — before UseAuthorization — so 403 errors from the host application's authorization policies can never block the viewer.

Authentication

Mode 1 — Form-based login (UseBasicAuth())

Credentials are validated against IConfiguration at request time (supports secret rotation without restart). On success a short-lived session cookie is issued:

  • Cookie .BusViewerAuth, path-scoped to /bus-viewer, HttpOnly, SameSite=Strict
  • Session-only: deleted when the browser closes, 8-hour absolute maximum
  • No browser credential caching (unlike HTTP Basic auth browser dialogs)
  • Logout at /bus-viewer/logout clears the cookie immediately
builder.Services.AddBusViewer(o =>o.UseBasicAuth(usernameConfigKey:"BusViewer:Username",// default keypasswordConfigKey:"BusViewer:Password"));// default key
{
"BusViewer": { "Username": "ops", "Password": "super-secret" }
}

Environment variable equivalents: BusViewer__Username, BusViewer__Password

Mode 2 — Decoupled / policy (recommended for production)

Delegates entirely to the host's authorization pipeline. Any scheme works (JWT, cookie, OIDC, Windows Auth):

builder.Services.AddAuthorization(o =>o.AddPolicy("OpsOnly", p =>p.RequireRole("ops")));builder.Services.AddBusViewer(o =>o.RequirePolicy("OpsOnly"));

Mode 3 — Anonymous (development only)

Throws InvalidOperationException at startup when IHostEnvironment.IsProduction() is true unless explicitly suppressed:

builder.Services.AddBusViewer(o =>{o.AllowAnonymous();o.AllowAnonymousInProduction=true;// only if behind proxy/network policy});

Viewer options reference

OptionDefaultDescription
Title"SW.Bus Operations"Header title displayed in the sidebar
AuthModeNoneSet via RequirePolicy(), UseBasicAuth(), or AllowAnonymous()
PolicyNamenullPolicy name used when AuthMode = Policy
UsernameConfigKey"BusViewer:Username"Config key for the login username
PasswordConfigKey"BusViewer:Password"Config key for the login password
AllowAnonymousInProductionfalseSuppress production guard for anonymous mode

Full BusOptions Reference

services.AddBus(config =>{// ── Identity ──────────────────────────────────────────────────────────config.ApplicationName="OrderService";// ── Connection ────────────────────────────────────────────────────────config.HeartBeatTimeOut=60;// heartbeat seconds (0 = off)config.ManagementUrl="http://localhost:15672";// defaults from AMQP URIconfig.ManagementUsername="guest";config.ManagementPassword="guest";config.VirtualHost="/";// ── Queue defaults ────────────────────────────────────────────────────config.DefaultQueuePrefetch=4;// QoS prefetch per consumerconfig.DefaultRetryCount=5;// retries before dead-letterconfig.DefaultRetryAfter=60;// seconds between retriesconfig.DefaultMaxPriority=0;// 0 = priority queues disabled// ── Per-queue overrides ───────────────────────────────────────────────config.AddQueueOption("OrderConsumer.OrderCreated",prefetch:10,retryCount:3,retryAfterSeconds:30,priority:5);// ── Broadcast listeners ───────────────────────────────────────────────config.ListenRetryCount=5;config.ListenRetryAfter=60;// ── Monitoring ────────────────────────────────────────────────────────config.MonitoringCacheSeconds=5;// cache management API responses (3–60)// ── JWT context propagation ───────────────────────────────────────────config.Token.Key="your-secret-key";config.Token.Issuer="your-issuer";config.Token.Audience="your-audience";// ── Operational events pipeline ───────────────────────────────────────config.OperationalEventsEnabled=true;config.OperationalEventsBufferCapacity=8192;// channel buffer before dropconfig.OperationalEventsBatchSize=256;// events per flush batchconfig.OperationalEventsFlushIntervalMs=1000;// ms between flushesconfig.OperationalEventsDropOldest=true;// drop strategy when buffer fullconfig.OperationalEventsSchemaVersion="1.0";// stamped on every eventconfig.OperationalEventsStoreCapacity=10000;// in-memory ring buffer size// ── Alert thresholds ─────────────────────────────────────────────────config.QueueBackpressureThreshold=5000;// queue depth → backpressure eventconfig.AlertRetryWarningThreshold=10;// retry count → Warning alertconfig.AlertRetryCriticalThreshold=100;// retry count → Critical alertconfig.AlertDeadLetterCriticalThreshold=100;// DL count → Critical alert});

Architecture

┌─────────────────────────────────────────────────────────────────┐
│ RabbitMQ Broker │
│ Process Exchange (direct) ──► Consumer Queue │
│ │ on failure │
│ ▼ │
│ Dead-Letter Exchange ──► Retry Queue ──► (TTL) ──► Consumer Q │
│ └──► Bad Queue (exhausted retries) │
│ │
│ Node Exchange (direct) ──► Per-Instance Queue (broadcasts) │
│ │
│ Delay Exchange (direct) ──────────────────────► Consumer Queue │
│ ├── [plugin path] Delay Plugin Exchange (x-delayed-message) │
│ └── [TTL fallback] Delay Entry Exchange (fanout) │
│ └──► Delay Bucket Queue (TTL) │
│ │ on expiry │
│ └──► Delay Exchange ─────► │
└─────────────────────────────────────────────────────────────────┘
▲ │
│ publish │ consume
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ SimplyWorks.Bus Runtime │
│ BasicPublisher ─── ActivitySource ─── OperationalEventPublisher│
│ ConsumerRunner ─── ActivitySource ─── OperationalEventPublisher│
│ ConsumersService ──────────────────── OperationalEventPublisher│
│ │
│ OperationalEventDispatcher (BackgroundService) │
│ ├── InMemoryOperationalEventStore (ring buffer) │
│ └── [custom IOperationalEventBatchSink sinks] │
│ │
│ BusDashboardDataService ── IConsumerReader (+ cache) │
│ ── IOperationalEventStore │
│ ── IAlertEvaluator │
└─────────────────────────────────────────────────────────────────┘
│
▼ (optional)
┌─────────────────────────────────────────────────────────────────┐
│ SimplyWorks.Bus.RabbitMqViewer │
│ /bus-viewer Overview dashboard │
│ /bus-viewer/consumers Consumer health grid │
│ /bus-viewer/queues Queue depths & rates │
│ /bus-viewer/retries Retry analysis │
│ /bus-viewer/dead-letters Dead-letter inspection │
│ /bus-viewer/events Live operational event stream │
└─────────────────────────────────────────────────────────────────┘

Queue naming convention:

{env}.{app}.{ConsumerClass}.{MessageType}
{env}.{app}.{ConsumerClass}.{MessageType}.retry
{env}.{app}.{ConsumerClass}.{MessageType}.bad

Example with ApplicationName = "OrderService" in Development:

v3.development.orderservice.ordercreatedconsumer.ordercreated
v3.development.orderservice.ordercreatedconsumer.ordercreated.retry
v3.development.orderservice.ordercreatedconsumer.ordercreated.bad

Testing

Unit tests

// Replace IPublish with a no-op — no RabbitMQ connection requiredservices.AddBusPublishMock();// IBusDashboardDataService, IConsumerReader, IErrorQueueReader are standard// interfaces — mock them with any mocking library

Integration tests

The SW.Bus.IntegrationTests project spins up real RabbitMQ containers via Testcontainers and tests the full delayed-publishing path against both broker variants. Docker must be running.

dotnet test SW.Bus.IntegrationTests/SW.Bus.IntegrationTests.csproj

What is tested

PluginAvailableTests — starts a container from heidiks/rabbitmq-delayed-message-exchange:3.13.0-management (the plugin is pre-enabled). Asserts that:

  • BusOptions.DelayedPluginAvailable is true (plugin was detected at startup).
  • A message published with a 5-second delay is not delivered before the delay elapses.
  • The message is delivered after the delay, routed via the x-delayed-message exchange.

PluginUnavailableTests — starts a container from stock rabbitmq:3.13-management (no plugin). Asserts that:

  • BusOptions.DelayedPluginAvailable is false.
  • A message published with a 5-second delay is not delivered before the delay elapses.
  • The message is delivered after the delay, routed via the TTL-bucket fallback.

Both test classes share the same scenario logic in DelayedDeliveryScenario.RunAsync, which boots a full generic host (AddBus + AddBusConsume + AddBusPublish), waits for consumer topology to bind, publishes a delayed message, checks it has not arrived early, then polls until it arrives or a 25-second deadline passes.

Structure

FileRole
BusHarnessBoots a real IHost against a container's connection string; waits for ConsumersService to declare topology
DelayedConsumerIConsume<DelayDto> — target consumer that records receive timestamps in MessageSink
MessageSinkSingleton ConcurrentDictionary of message id → received-at timestamp
DelayedDeliveryScenarioShared assertions used by both test classes
PluginAvailableTestsContainer with the delayed-message plugin
PluginUnavailableTestsStock container, exercises TTL buckets

Dependencies

PackageVersionUsed for
RabbitMQ.Client6.8.1AMQP connection and channel management
EasyNetQ.Management.Client3.0.1RabbitMQ Management API calls
Scrutor4.2.2Assembly scanning for consumers
SimplyWorks.HttpExtensions8.1.1JWT and request context propagation
SimplyWorks.PrimitiveTypes8.1.3RequestContext, IConsume<T>, etc.

Contributing

  1. Fork the repository
  2. Create a feature branch
  3. Make your changes with tests
  4. Submit a pull request

License

MIT — see LICENSE

Support

About

Lightweight .NET message bus library over RabbitMQ for event-driven microservices

Topics

Resources

Code of conduct

Contributing

Stars

3 stars

Watchers

3 watching

Forks

Releases

Packages

Used by

Contributors

Languages