Monitoring & Observability¶
Verified by tests
WhizbangActivitySourceTests, TableStatisticsMetricsTests, DispatcherMetricsTests, SubscriptionHealthCheckTests — library CI run #31657041675 (2026-08-13)
Comprehensive monitoring and observability for Whizbang applications - Application Insights, Prometheus metrics, distributed tracing, health checks, and dashboards.
Observability Pillars¶
| Pillar | Tool | Purpose |
|---|---|---|
| Logs | Application Insights | Structured logging and queries |
| Metrics | Prometheus + Grafana | Time-series metrics and dashboards |
| Traces | Application Insights | Distributed tracing across services |
| Health | ASP.NET Health Checks | Service health and dependencies |
Application Insights¶
Setup¶
Program.cs:
Setup
builder.Services.AddApplicationInsightsTelemetry(options => {
options.ConnectionString = builder.Configuration["ApplicationInsights:ConnectionString"];
options.EnableAdaptiveSampling = true;
options.EnableDependencyTrackingTelemetryModule = true;
options.EnablePerformanceCounterCollectionModule = true;
});
builder.Services.AddApplicationInsightsTelemetryProcessor<FilterHealthChecksTelemetryProcessor>();
appsettings.json:
Setup (2)
{
"ApplicationInsights": {
"ConnectionString": "InstrumentationKey=...;IngestionEndpoint=https://..."
}
}
Structured Logging¶
Structured Logging
public class CreateOrderReceptor(
IDispatcher dispatcher,
ILogger<CreateOrderReceptor> logger) : IReceptor<CreateOrderCommand, OrderCreatedEvent> {
public async ValueTask<OrderCreatedEvent> HandleAsync(
CreateOrderCommand command,
CancellationToken cancellationToken = default
) {
using (logger.BeginScope(new Dictionary<string, object> {
["OrderId"] = command.OrderId,
["CustomerId"] = command.CustomerId
})) {
logger.LogInformation(
"Creating order for customer {CustomerId} with {ItemCount} items",
command.CustomerId,
command.LineItems.Count
);
try {
// Process order...
var orderCreated = new OrderCreatedEvent {
OrderId = command.OrderId,
CustomerId = command.CustomerId,
LineItems = command.LineItems,
TotalAmount = command.TotalAmount,
CreatedAt = DateTime.UtcNow
};
await dispatcher.PublishAsync(orderCreated);
logger.LogInformation(
"Order {OrderId} created successfully with total amount {TotalAmount:C}",
command.OrderId,
command.TotalAmount
);
return orderCreated;
} catch (Exception ex) {
logger.LogError(
ex,
"Failed to create order for customer {CustomerId}",
command.CustomerId
);
throw;
}
}
}
}
Kusto Queries (Application Insights)¶
Query 1: Error rate by operation:
requests
| where timestamp > ago(1h)
| summarize
Total = count(),
Errors = countif(success == false),
ErrorRate = 100.0 * countif(success == false) / count()
by name
| order by ErrorRate desc
Query 2: P95 latency by operation:
requests
| where timestamp > ago(1h)
| summarize
p50 = percentile(duration, 50),
p95 = percentile(duration, 95),
p99 = percentile(duration, 99)
by name
| order by p95 desc
Query 3: Failed operations with traces:
requests
| where timestamp > ago(1h) and success == false
| join kind=inner (
traces
| where timestamp > ago(1h)
) on operation_Id
| project
timestamp,
operation_Name,
resultCode,
message,
customDimensions
| order by timestamp desc
Prometheus Metrics¶
Setup¶
Program.cs:
Setup (3)
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => {
metrics
.AddMeter("Whizbang.*") // Wildcard subscribes all Whizbang meters
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddPrometheusExporter();
});
app.MapPrometheusScrapingEndpoint(); // /metrics endpoint
The Whizbang.* wildcard picks up the library's meters, including Whizbang.Dispatcher, Whizbang.WorkCoordinator, Whizbang.Perspectives, Whizbang.Transport, Whizbang.Lifecycle, Whizbang.DeadLetters, and Whizbang.TableStatistics (queue depth / table size gauges). Instrument names follow the whizbang.<component>.<measurement> convention, e.g. whizbang.dispatcher.send.duration, whizbang.work_coordinator.process_batch.duration, whizbang.queue.estimated_depth. Add your own app meters explicitly (e.g. .AddMeter("ECommerce.OrderService")).
Custom Metrics¶
OrderMetrics.cs:
Custom Metrics
using System.Diagnostics.Metrics;
public class OrderMetrics {
// Use your app's namespace, not "Whizbang.*" - that prefix belongs to the library's meters
private static readonly Meter Meter = new("ECommerce.OrderService");
private static readonly Counter<long> OrdersCreated = Meter.CreateCounter<long>(
"orders_created_total",
description: "Total number of orders created"
);
private static readonly Histogram<double> OrderAmount = Meter.CreateHistogram<double>(
"order_amount",
unit: "USD",
description: "Order amount distribution"
);
private static readonly ObservableGauge<int> ActiveOrders = Meter.CreateObservableGauge<int>(
"active_orders",
observeValue: () => GetActiveOrderCount(),
description: "Current number of active orders"
);
public static void RecordOrderCreated(decimal amount) {
OrdersCreated.Add(1);
OrderAmount.Record((double)amount);
}
private static int GetActiveOrderCount() {
// Query database for active orders
return 0; // Placeholder
}
}
Usage:
Custom Metrics (2)
public async ValueTask<OrderCreatedEvent> HandleAsync(
CreateOrderCommand command,
CancellationToken cancellationToken = default
) {
// Process order...
OrderMetrics.RecordOrderCreated(command.TotalAmount);
return new OrderCreatedEvent {
OrderId = command.OrderId,
CustomerId = command.CustomerId,
LineItems = command.LineItems,
TotalAmount = command.TotalAmount,
CreatedAt = DateTime.UtcNow
};
}
Prometheus Queries (PromQL)¶
Query 1: Request rate (requests/second):
Query 2: Error rate percentage:
Query 3: P95 latency:
Query 4: Orders created per minute:
Distributed Tracing¶
Register Whizbang Activity Sources¶
Whizbang emits OpenTelemetry spans through named ActivitySources (see WhizbangActivitySource). Register them with your tracer provider:
Register Whizbang Activity Sources
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => {
tracing
.AddSource("Whizbang.Execution") // Dispatch activities (parent spans)
.AddSource("Whizbang.Tracing") // Handler traces (child spans for [WhizbangTrace])
.AddSource("Whizbang.Transport") // Transport operations
.AddSource("Whizbang.Hosting") // Hosting/infrastructure operations
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation();
});
Span emission is controlled by TracingOptions (options.Tracing.EnableOpenTelemetry, on by default, plus Verbosity / Components) - see Tracing and Verbosity Levels.
Trace Context Propagation Is Automatic¶
You do not hand-roll trace propagation with Whizbang. Every message travels inside a MessageEnvelope (Whizbang.Core.Observability) that carries MessageId, per-service MessageHops with CorrelationId and CausationId, and the dispatch context - across the outbox, the transport, and the inbox. The consuming side restores the context before your receptors run, so spans from different services join the same trace.
Custom Spans in Receptors¶
Add your own child spans with a private ActivitySource when you need business-level detail:
Custom Spans in Receptors
public class CreateOrderReceptor(IDispatcher dispatcher) : IReceptor<CreateOrderCommand, OrderCreatedEvent> {
private static readonly ActivitySource Source = new("ECommerce.OrderService");
public async ValueTask<OrderCreatedEvent> HandleAsync(
CreateOrderCommand command,
CancellationToken cancellationToken = default
) {
using var activity = Source.StartActivity("CreateOrder");
activity?.SetTag("order.customer_id", command.CustomerId.ToString());
activity?.SetTag("order.item_count", command.LineItems.Count);
try {
// Process order...
var orderCreated = new OrderCreatedEvent {
OrderId = command.OrderId,
CustomerId = command.CustomerId,
LineItems = command.LineItems,
TotalAmount = command.TotalAmount,
CreatedAt = DateTime.UtcNow
};
await dispatcher.PublishAsync(orderCreated);
activity?.SetTag("order.total_amount", command.TotalAmount);
activity?.SetStatus(ActivityStatusCode.Ok);
return orderCreated;
} catch (Exception ex) {
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
}
Remember to register the app-level source too: tracing.AddSource("ECommerce.OrderService"). Because Whizbang already created the parent activity for the dispatch, your span nests inside the distributed trace automatically.
Health Checks¶
Basic Health Checks¶
Program.cs:
Basic Health Checks
builder.Services.AddHealthChecks()
.AddNpgSql(
builder.Configuration["Database:ConnectionString"],
name: "database",
tags: ["db", "postgres"]
)
.AddAzureServiceBusTopic(
builder.Configuration["AzureServiceBus:ConnectionString"],
"orders",
name: "servicebus",
tags: ["messaging", "servicebus"]
)
.AddUrlGroup(
new Uri("https://api.stripe.com/v1/health"),
name: "stripe",
tags: ["external", "payment"]
);
app.MapHealthChecks("/health", new HealthCheckOptions {
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions {
Predicate = check => check.Tags.Contains("ready")
});
app.MapHealthChecks("/health/live", new HealthCheckOptions {
Predicate = _ => true
});
Built-In Whizbang Health Checks¶
Whizbang packages register named checks on the standard health-check pipeline automatically:
| Check name | Package | Reports |
|---|---|---|
subscriptions |
Whizbang.Core | Transport subscription state (Degraded when some subscriptions are down, Unhealthy when all are) |
whizbang_postgres |
Whizbang.Data.Dapper.Postgres | Postgres storage connectivity |
azure_servicebus |
Whizbang.Transports.AzureServiceBus | Azure Service Bus connectivity |
rabbitmq |
Whizbang.Transports.RabbitMQ (opt-in via AddRabbitMQHealthChecks()) |
RabbitMQ connectivity |
The subscriptions check is tagged transport, so you can include it in a readiness predicate with check => check.Tags.Contains("transport").
Custom Health Check¶
OrderServiceHealthCheck.cs:
Custom Health Check
public class OrderServiceHealthCheck : IHealthCheck {
private readonly IDbConnection _db;
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken ct = default
) {
try {
// Check database connectivity
var count = await _db.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM orders LIMIT 1");
// Check outbox backlog (Whizbang's internal outbox table)
var outboxBacklog = await _db.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM wh_outbox WHERE processed_at IS NULL"
);
if (outboxBacklog > 10000) {
return HealthCheckResult.Degraded(
$"Outbox backlog is {outboxBacklog} messages",
data: new Dictionary<string, object> {
["outbox_backlog"] = outboxBacklog
}
);
}
return HealthCheckResult.Healthy("Order service is healthy", data: new Dictionary<string, object> {
["outbox_backlog"] = outboxBacklog
});
} catch (Exception ex) {
return HealthCheckResult.Unhealthy("Order service is unhealthy", ex);
}
}
}
Registration:
Custom Health Check (2)
builder.Services.AddHealthChecks()
.AddCheck<OrderServiceHealthCheck>("order-service", tags: ["ready"]);
Dashboards¶
Grafana Dashboard (JSON)¶
orders-dashboard.json:
Grafana Dashboard (JSON)
{
"dashboard": {
"title": "Order Service Metrics",
"panels": [
{
"title": "Request Rate",
"targets": [
{
"expr": "rate(http_requests_total{service=\"order-service\"}[5m])"
}
],
"type": "graph"
},
{
"title": "Error Rate",
"targets": [
{
"expr": "100 * (rate(http_requests_total{service=\"order-service\",status=~\"5..\"}[5m]) / rate(http_requests_total{service=\"order-service\"}[5m]))"
}
],
"type": "graph"
},
{
"title": "P95 Latency",
"targets": [
{
"expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket{service=\"order-service\"}[5m]))"
}
],
"type": "graph"
},
{
"title": "Orders Created",
"targets": [
{
"expr": "rate(orders_created_total[1m]) * 60"
}
],
"type": "graph"
}
]
}
}
Azure Dashboard (KQL)¶
orders-dashboard.kql:
// Request rate
requests
| where timestamp > ago(1h)
| summarize RequestRate = count() / 60.0 by bin(timestamp, 1m)
| render timechart
// Error rate
requests
| where timestamp > ago(1h)
| summarize
Total = count(),
Errors = countif(success == false)
by bin(timestamp, 1m)
| extend ErrorRate = 100.0 * Errors / Total
| render timechart
// P95 latency
requests
| where timestamp > ago(1h)
| summarize p95 = percentile(duration, 95) by bin(timestamp, 1m)
| render timechart
// Top slow operations
requests
| where timestamp > ago(1h)
| summarize p95 = percentile(duration, 95) by name
| top 10 by p95 desc
| render barchart
Alerts¶
Prometheus Alerts¶
alerts.yml:
Prometheus Alerts
groups:
- name: order-service
interval: 30s
rules:
- alert: HighErrorRate
expr: |
100 * (
rate(http_requests_total{service="order-service",status=~"5.."}[5m])
/
rate(http_requests_total{service="order-service"}[5m])
) > 5
for: 5m
labels:
severity: critical
annotations:
summary: "High error rate on Order Service"
description: "Error rate is {{ $value }}% over the last 5 minutes"
- alert: HighLatency
expr: |
histogram_quantile(0.95,
rate(http_request_duration_seconds_bucket{service="order-service"}[5m])
) > 1.0
for: 5m
labels:
severity: warning
annotations:
summary: "High latency on Order Service"
description: "P95 latency is {{ $value }}s over the last 5 minutes"
- alert: OutboxBacklog
expr: whizbang_queue_estimated_depth{queue_name="outbox"} > 10000
for: 10m
labels:
severity: warning
annotations:
summary: "Outbox backlog is high"
description: "Outbox has {{ $value }} unprocessed messages"
Application Insights Alerts¶
Azure CLI:
Application Insights Alerts
# Create alert for error rate
az monitor metrics alert create \
--name "High Error Rate" \
--resource-group whizbang-rg \
--scopes /subscriptions/.../resourceGroups/whizbang-rg/providers/Microsoft.Insights/components/whizbang-ai \
--condition "count requests/failed > 50" \
--window-size 5m \
--evaluation-frequency 1m \
--severity 2 \
--description "Error rate exceeded 50 requests/5min"
# Create alert for P95 latency
az monitor metrics alert create \
--name "High Latency" \
--resource-group whizbang-rg \
--scopes /subscriptions/.../resourceGroups/whizbang-rg/providers/Microsoft.Insights/components/whizbang-ai \
--condition "percentile requests/duration > 1000" \
--window-size 5m \
--evaluation-frequency 1m \
--severity 3 \
--description "P95 latency exceeded 1 second"
Log Aggregation¶
Serilog with Sinks¶
Program.cs:
Serilog with Sinks
using Serilog;
using Serilog.Sinks.ApplicationInsights.TelemetryConverters;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.WriteTo.Console(new JsonFormatter())
.WriteTo.ApplicationInsights(
builder.Configuration["ApplicationInsights:ConnectionString"],
TelemetryConverter.Traces
)
.CreateLogger();
builder.Host.UseSerilog();
appsettings.json:
Serilog with Sinks (2)
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
}
}
}
Performance Monitoring¶
BenchmarkDotNet Integration¶
CreateOrderBenchmark.cs:
BenchmarkDotNet Integration
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser]
[SimpleJob(warmupCount: 3, iterationCount: 10)]
public class CreateOrderBenchmark {
private CreateOrderReceptor _receptor = null!;
private CreateOrderCommand _command = null!;
[GlobalSetup]
public void Setup() {
_receptor = new CreateOrderReceptor(
new TestDispatcher(), // recording IDispatcher fake
NullLogger<CreateOrderReceptor>.Instance);
_command = new CreateOrderCommand {
OrderId = OrderId.New(),
CustomerId = CustomerId.New(),
LineItems = [
new OrderLineItem {
ProductId = ProductId.New(),
ProductName = "Widget",
Quantity = 2,
UnitPrice = 19.99m
}
],
TotalAmount = 39.98m
};
}
[Benchmark]
public async Task<OrderCreatedEvent> CreateOrder() {
return await _receptor.HandleAsync(_command);
}
}
Run:
BenchmarkDotNet Integration (2)
dotnet run -c Release --project Benchmarks
# Output:
# | Method | Mean | Error | StdDev | Allocated |
# |------------ |---------:|--------:|-------:|----------:|
# | CreateOrder | 125.3 μs | 2.34 μs | 2.19 μs | 512 B |
Key Takeaways¶
✅ Application Insights - Logs, metrics, traces in one platform ✅ Prometheus + Grafana - Time-series metrics and dashboards ✅ Distributed Tracing - W3C Trace Context propagation ✅ Health Checks - Readiness and liveness probes ✅ Custom Metrics - Business-specific KPIs ✅ Alerts - Proactive incident detection ✅ Structured Logging - Queryable logs with context
Monitoring Checklist¶
- [ ] Application Insights configured with connection string
- [ ] Prometheus metrics exported at
/metrics - [ ] Distributed tracing enabled with W3C Trace Context
- [ ] Health checks at
/health,/health/ready,/health/live - [ ] Custom metrics for business KPIs (orders created, revenue, etc.)
- [ ] Alerts configured for error rate, latency, backlog
- [ ] Dashboards created in Grafana and Azure Portal
- [ ] Log aggregation with Serilog
- [ ] Performance benchmarks with BenchmarkDotNet
Version 1.0.0 - Foundation Release | Last Updated: 2024-12-12