Health Checks in ConnectSoft Microservice Template¶
Purpose & Overview¶
Health Checks in the ConnectSoft Microservice Template provide a standardized, comprehensive mechanism for monitoring the operational status of microservices and their dependencies. Health checks enable orchestrators (Kubernetes, Azure App Gateway, load balancers), monitoring systems, and CI/CD pipelines to make informed decisions about service availability, traffic routing, and deployment readiness.
Why Health Checks Matter¶
Health checks provide critical capabilities:
- Orchestrator Integration: Kubernetes liveness/readiness probes, Azure App Gateway backend health validation
- Traffic Management: Load balancers route traffic only to healthy services
- Observability: Dashboard visualization, alerting, and trend analysis
- Deployment Safety: Pre-deployment validation and rolling update coordination
- Testing: Automated acceptance tests for service startup and dependency availability
- Debugging: Runtime diagnostics for developers and operations teams
Health Checks Philosophy
Health checks in ConnectSoft are production-first, configurable, and integrated with observability systems. Every microservice exposes standardized health endpoints that reflect both internal state and external dependency status.
Architecture Overview¶
Health checks in ConnectSoft operate at multiple layers:
Health Check System
├── Endpoints (HTTP/gRPC)
│ ├── /health (readiness - all tagged checks)
│ ├── /alive (liveness - process-level checks)
│ └── /startup (startup probe - warmup checks)
├── Registration Layer (HealthChecksExtensions)
│ ├── System Checks (memory, disk, GC)
│ ├── Infrastructure Checks (SQL, MongoDB, Redis)
│ ├── AI Service Checks (Chat Clients, Embedding Generators, Vector Store)
│ ├── Messaging Checks (RabbitMQ, MassTransit, NServiceBus)
│ ├── Actor Model Checks (Orleans cluster, silo, grain, storage)
│ └── Service Checks (SignalR, gRPC, Hangfire)
├── Publishers (Observability)
│ ├── Seq (structured logging)
│ ├── Application Insights (Azure telemetry)
│ └── OpenTelemetry (metrics/traces)
└── UI Dashboard (HealthChecks.UI)
└── Visual status monitoring and history
Endpoint Structure¶
Standard Endpoints¶
The template provides three standardized HTTP endpoints:
| Endpoint | Purpose | Tag Filter | HTTP Status | Use Case |
|---|---|---|---|---|
/health |
Readiness probe | ready |
200 OK / 503 Service Unavailable |
Load balancer routing, deployment validation |
/alive |
Liveness probe | live |
200 OK / 503 Service Unavailable |
Kubernetes pod restart decisions |
/startup |
Startup probe | startup |
200 OK / 503 Service Unavailable |
Kubernetes initial startup grace period |
Endpoint Configuration¶
Endpoints are mapped in HealthChecksExtensions.cs:
internal static IEndpointRouteBuilder MapMicroserviceHealthChecks(this IEndpointRouteBuilder endpoints)
{
HealthChecksOptions healthChecksOptions = endpoints.ServiceProvider
.GetRequiredService<IOptions<HealthChecksOptions>>().Value;
// Readiness endpoint
var endpointsBuilder = endpoints.MapHealthChecks("HEALTHCHECK-PATH", new HealthCheckOptions()
{
AllowCachingResponses = healthChecksOptions.AllowCachingResponses,
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse,
Predicate = r => r.Tags.Contains("ready"),
ResultStatusCodes =
{
[HealthStatus.Healthy] = StatusCodes.Status200OK,
[HealthStatus.Degraded] = StatusCodes.Status500InternalServerError,
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable,
},
});
// Liveness endpoint
endpointsBuilder = endpoints.MapHealthChecks("/alive", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live"),
});
// Startup endpoint
endpointsBuilder = endpoints.MapHealthChecks("/startup", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("startup"),
});
#if UseGrpc
// gRPC health service
endpointsBuilder = endpoints.MapGrpcHealthChecksService();
#endif
return endpoints;
}
Response Format¶
Health check endpoints return JSON responses:
{
"status": "Unhealthy",
"totalDuration": "00:00:00.2431254",
"entries": {
"sql": {
"status": "Healthy",
"duration": "00:00:00.0129347"
},
"orleans-cluster": {
"status": "Unhealthy",
"description": "Failed cluster status health check.",
"duration": "00:00:00.1000980"
},
"redis": {
"status": "Unhealthy",
"description": "Timeout",
"duration": "00:00:00.0873943"
}
}
}
Status Code Mapping¶
| Health Status | HTTP Code | Meaning |
|---|---|---|
Healthy |
200 OK |
Service is operational and ready |
Degraded |
500 Internal Server Error |
Service is operational but in degraded state |
Unhealthy |
503 Service Unavailable |
Service is not operational |
Health Check Registration¶
Centralized Registration¶
All health checks are registered in HealthChecksExtensions.cs:
internal static IServiceCollection AddMicroserviceHealthChecks(
this IServiceCollection services,
IConfiguration configuration)
{
IHealthChecksBuilder builder = services.AddHealthChecks();
// System-level checks
builder.AddSystemHealthChecks();
#if ResourceMonitoring
builder.AddAndConfigureResourceUtilizationHealthCheck();
#endif
#if UseNHibernate
builder.AndAndConfigureNHibernatePersistenceHealthCheck(configuration);
#endif
#if UseMongoDb
builder.AddMongoDb(/* ... */);
#endif
#if DistributedCacheRedis
builder.AddRedis(/* ... */);
#endif
#if UseGrpc
services.AddGrpcHealthChecks(configure =>
{
configure.Services.Map(string.Empty, reg => reg.Tags.Contains("ready", StringComparer.OrdinalIgnoreCase));
configure.Services.Map("all", _ => true);
});
#endif
#if UseNServiceBus
builder.AddNServiceBusHealthChecks(configuration);
#endif
#if UseMassTransit
builder.AddMassTransitHealthChecks(services);
#endif
#if UseOrleans
builder.AddOrleansHealthChecks();
#endif
#if UseHangFire
builder.AddHangfireHealthChecks();
#endif
#if UseSignalR
builder.AddSignalRHealthChecks();
#endif
// Default liveness check
builder.AddCheck(
name: "self",
() => HealthCheckResult.Healthy(),
tags: new string[] { "live" });
// Startup warmup check
builder.Add(new HealthCheckRegistration(
name: "startup-gate",
factory: sp => new DelegateHealthCheck(_ =>
{
var gate = sp.GetRequiredService<StartupWarmupGate>();
return Task.FromResult(
gate.IsReady
? HealthCheckResult.Healthy("Warmup complete.")
: HealthCheckResult.Unhealthy("Warming up (startup grace period not elapsed)."));
}),
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "startup" }));
#if HealthCheckUI
if (!IsRunningUnderTestHost())
{
services.AddAndConfigureHealthChecksUI(configuration);
}
#endif
builder.AddHealthChecksReportsPublishers(OptionsExtensions.HealthChecksOptions);
return services;
}
System-Level Health Checks¶
Memory and Process Checks¶
System checks monitor process-level resources:
private static void AddSystemHealthChecks(this IHealthChecksBuilder builder)
{
const int oneHundredMb = 104857600; // 100 MB
// Disk storage health check (cross-platform)
builder.AddDiskStorageHealthCheck(storage =>
{
if (OperatingSystem.IsWindows())
{
string rootDrive = Path.GetPathRoot(Environment.SystemDirectory);
storage.AddDrive(rootDrive, 1024 * 5); // 5 GB free minimum
}
else if (OperatingSystem.IsLinux())
{
storage.AddDrive("/", 1024 * 5); // 5 GB free minimum
}
}, tags: new string[] { "diskstorage" });
// Process allocated memory
builder.AddProcessAllocatedMemoryHealthCheck(
maximumMegabytesAllocated: 1024,
tags: new string[] { "allocatedmemory" });
// Windows-specific checks
if (OperatingSystem.IsWindows())
{
builder
.AddPrivateMemoryHealthCheck(
Process.GetCurrentProcess().PrivateMemorySize64 + (oneHundredMb * 10),
tags: new string[] { "privatememory" })
.AddWorkingSetHealthCheck(
Process.GetCurrentProcess().WorkingSet64 + (oneHundredMb * 10),
tags: new string[] { "workingset" })
.AddVirtualMemorySizeHealthCheck(
Process.GetCurrentProcess().VirtualMemorySize64 + (oneHundredMb * 10),
tags: new string[] { "virtualmemory" });
}
else
{
// Cross-platform memory check for Linux/Mac
builder.AddCrossPlatformMemoryHealthCheck();
}
}
Cross-Platform Memory Check¶
For non-Windows platforms:
private static void AddCrossPlatformMemoryHealthCheck(this IHealthChecksBuilder builder)
{
builder.AddCheck(
"Memory",
() =>
{
var threshold = 1_000_000_000; // 1 GB
var totalMemory = GC.GetTotalMemory(forceFullCollection: false);
return totalMemory < threshold
? HealthCheckResult.Healthy($"Total memory is {totalMemory / 1_000_000} MB.")
: HealthCheckResult.Unhealthy($"Total memory is {totalMemory / 1_000_000} MB, exceeds threshold of {threshold / 1_000_000} MB.");
},
tags: new string[] { "memory" });
}
Resource Utilization Check¶
Optional resource monitoring with CPU and memory thresholds:
#if ResourceMonitoring
private static IHealthChecksBuilder AddAndConfigureResourceUtilizationHealthCheck(
this IHealthChecksBuilder healthChecksBuilder)
{
if (OperatingSystem.IsWindows())
{
return healthChecksBuilder
.AddResourceUtilizationHealthCheck(
options =>
{
options.CpuThresholds = new ResourceUsageThresholds
{
DegradedUtilizationPercentage = 80,
UnhealthyUtilizationPercentage = 90,
};
options.MemoryThresholds = new ResourceUsageThresholds
{
DegradedUtilizationPercentage = 80,
UnhealthyUtilizationPercentage = 90,
};
},
tags: "resource utilization");
}
return healthChecksBuilder;
}
#endif
Database Health Checks¶
SQL Server (NHibernate)¶
For SQL Server persistence:
#if UseNHibernate
private static void AndAndConfigureNHibernatePersistenceHealthCheck(
this IHealthChecksBuilder builder,
IConfiguration configuration)
{
string dbConnectionString = configuration.GetConnectionString(
OptionsExtensions.PersistenceModelOptions.NHibernate.NHibernateConnectionStringKey);
builder.AddSqlServer(
dbConnectionString,
healthQuery: "SELECT 1;",
name: "ConnectSoft.MicroserviceTemplate database health check",
tags: new string[] { "application sqlserver", "ready" });
}
#endif
MongoDB¶
For MongoDB persistence:
#if UseMongoDb
builder.AddMongoDb(
clientFactory: (serviceProvider) =>
{
if (!string.IsNullOrEmpty(MicroserviceConstants.MongoDbDIKey))
{
return serviceProvider.GetRequiredKeyedService<IMongoClient>(MicroserviceConstants.MongoDbDIKey);
}
else
{
return serviceProvider.GetRequiredService<IMongoClient>();
}
},
databaseNameFactory: (serviceProvider) =>
{
return OptionsExtensions.PersistenceModelOptions.MongoDb.DatabaseName;
},
name: "ConnectSoft.MicroserviceTemplate mongDb health check",
tags: new string[] { "application mongodb", "ready" });
#endif
Redis¶
For distributed caching:
#if DistributedCacheRedis
string redisConnectionString = configuration.GetConnectionString(
DistributedCacheRedisExtensions.RedisConnectionStringName);
builder.AddRedis(
redisConnectionString,
name: "ConnectSoft.MicroserviceTemplate redis health check",
tags: new string[] { "application redis", "ready" });
#endif
AI Health Checks¶
Microsoft.Extensions.AI Chat Completion Health Checks¶
Health checks validate that AI chat completion clients are properly registered:
#if HealthCheck && UseMicrosoftExtensionsAI
private static IHealthChecksBuilder AddMicrosoftExtensionsAIChatCompletionHealthChecks(this IHealthChecksBuilder builder)
{
#if UseOpenAI
builder.AddCheck(
name: "OpenAI Chat Client",
check: sp =>
{
var chatClient = sp.GetKeyedService<Microsoft.Extensions.AI.IChatClient>("openAI");
if (chatClient == null)
{
return Task.FromResult(HealthCheckResult.Unhealthy("OpenAI chat client is not registered."));
}
return Task.FromResult(HealthCheckResult.Healthy("OpenAI chat client is registered and available."));
},
tags: new string[] { "ai", "chat", "openai", "ready" });
#endif
#if UseAzureOpenAI
builder.AddCheck(
name: "Azure OpenAI Chat Client",
check: sp =>
{
var chatClient = sp.GetKeyedService<Microsoft.Extensions.AI.IChatClient>("azureOpenAI");
if (chatClient == null)
{
return Task.FromResult(HealthCheckResult.Unhealthy("Azure OpenAI chat client is not registered."));
}
return Task.FromResult(HealthCheckResult.Healthy("Azure OpenAI chat client is registered and available."));
},
tags: new string[] { "ai", "chat", "azureopenai", "ready" });
#endif
#if UseOllama
builder.AddCheck(
name: "Ollama Chat Client",
check: sp =>
{
var chatClient = sp.GetKeyedService<Microsoft.Extensions.AI.IChatClient>("ollama");
if (chatClient == null)
{
return Task.FromResult(HealthCheckResult.Unhealthy("Ollama chat client is not registered."));
}
return Task.FromResult(HealthCheckResult.Healthy("Ollama chat client is registered and available."));
},
tags: new string[] { "ai", "chat", "ollama", "ready" });
#endif
#if UseMicrosoftExtensionsAIAzureAIInferenceProvider
builder.AddCheck(
name: "Azure AI Inference Chat Client",
check: sp =>
{
var chatClient = sp.GetKeyedService<Microsoft.Extensions.AI.IChatClient>("azureAIInference");
if (chatClient == null)
{
return Task.FromResult(HealthCheckResult.Unhealthy("Azure AI Inference chat client is not registered."));
}
return Task.FromResult(HealthCheckResult.Healthy("Azure AI Inference chat client is registered and available."));
},
tags: new string[] { "ai", "chat", "azureaiinference", "ready" });
#endif
return builder;
}
#endif
Available Checks:
- ✅ OpenAI Chat Client (when UseOpenAI is enabled)
- ✅ Azure OpenAI Chat Client (when UseAzureOpenAI is enabled)
- ✅ Ollama Chat Client (when UseOllama is enabled)
- ✅ Azure AI Inference Chat Client (when UseMicrosoftExtensionsAIAzureAIInferenceProvider is enabled)
Microsoft.Extensions.AI Embedding Generator Health Checks¶
Health checks validate that embedding generators are properly registered:
#if HealthCheck && UseMicrosoftExtensionsAIEmbedding
private static IHealthChecksBuilder AddMicrosoftExtensionsAIEmbeddingHealthChecks(this IHealthChecksBuilder builder)
{
#if UseMicrosoftExtensionsAIOpenAIEmbeddingProvider
builder.AddCheck(
name: "OpenAI Embedding Generator",
check: sp =>
{
var embeddingGenerator = sp.GetKeyedService<Microsoft.Extensions.AI.IEmbeddingGenerator<string, Microsoft.Extensions.AI.Embedding<float>>>("openAI");
if (embeddingGenerator == null)
{
return Task.FromResult(HealthCheckResult.Unhealthy("OpenAI embedding generator is not registered."));
}
return Task.FromResult(HealthCheckResult.Healthy("OpenAI embedding generator is registered and available."));
},
tags: new string[] { "ai", "embedding", "openai", "ready" });
#endif
#if UseMicrosoftExtensionsAIAzureOpenAIEmbeddingProvider
builder.AddCheck(
name: "Azure OpenAI Embedding Generator",
check: sp =>
{
var embeddingGenerator = sp.GetKeyedService<Microsoft.Extensions.AI.IEmbeddingGenerator<string, Microsoft.Extensions.AI.Embedding<float>>>("azureOpenAI");
if (embeddingGenerator == null)
{
return Task.FromResult(HealthCheckResult.Unhealthy("Azure OpenAI embedding generator is not registered."));
}
return Task.FromResult(HealthCheckResult.Healthy("Azure OpenAI embedding generator is registered and available."));
},
tags: new string[] { "ai", "embedding", "azureopenai", "ready" });
#endif
#if UseMicrosoftExtensionsAIAzureAIInferenceEmbeddingProvider
builder.AddCheck(
name: "Azure AI Inference Embedding Generator",
check: sp =>
{
var embeddingGenerator = sp.GetKeyedService<Microsoft.Extensions.AI.IEmbeddingGenerator<string, Microsoft.Extensions.AI.Embedding<float>>>("azureAIInference");
if (embeddingGenerator == null)
{
return Task.FromResult(HealthCheckResult.Unhealthy("Azure AI Inference embedding generator is not registered."));
}
return Task.FromResult(HealthCheckResult.Healthy("Azure AI Inference embedding generator is registered and available."));
},
tags: new string[] { "ai", "embedding", "azureaiinference", "ready" });
#endif
#if UseMicrosoftExtensionsAIOllamaEmbeddingProvider
builder.AddCheck(
name: "Ollama Embedding Generator",
check: sp =>
{
var embeddingGenerator = sp.GetKeyedService<Microsoft.Extensions.AI.IEmbeddingGenerator<string, Microsoft.Extensions.AI.Embedding<float>>>("ollama");
if (embeddingGenerator == null)
{
return Task.FromResult(HealthCheckResult.Unhealthy("Ollama embedding generator is not registered."));
}
return Task.FromResult(HealthCheckResult.Healthy("Ollama embedding generator is registered and available."));
},
tags: new string[] { "ai", "embedding", "ollama", "ready" });
#endif
return builder;
}
#endif
Available Checks:
- ✅ OpenAI Embedding Generator (when UseMicrosoftExtensionsAIOpenAIEmbeddingProvider is enabled)
- ✅ Azure OpenAI Embedding Generator (when UseMicrosoftExtensionsAIAzureOpenAIEmbeddingProvider is enabled)
- ✅ Azure AI Inference Embedding Generator (when UseMicrosoftExtensionsAIAzureAIInferenceEmbeddingProvider is enabled)
- ✅ Ollama Embedding Generator (when UseMicrosoftExtensionsAIOllamaEmbeddingProvider is enabled)
Vector Store Health Checks¶
Health checks validate vector store configuration and embedding generator availability:
#if HealthCheck && UseVectorStore
private static IHealthChecksBuilder AddVectorStoreHealthChecks(this IHealthChecksBuilder builder)
{
builder.AddCheck(
name: "Vector Store",
check: sp =>
{
var options = OptionsExtensions.MicrosoftExtensionsAIOptions.VectorStore;
if (options == null || !options.Enabled)
{
return Task.FromResult(HealthCheckResult.Healthy("Vector store is disabled."));
}
if (string.IsNullOrWhiteSpace(options.EmbeddingGeneratorKey))
{
return Task.FromResult(HealthCheckResult.Unhealthy("Vector store is enabled but EmbeddingGeneratorKey is not configured."));
}
// Verify embedding generator is registered
var embeddingGenerator = sp.GetKeyedService<Microsoft.Extensions.AI.IEmbeddingGenerator<string, Microsoft.Extensions.AI.Embedding<float>>>(options.EmbeddingGeneratorKey);
if (embeddingGenerator == null)
{
return Task.FromResult(HealthCheckResult.Unhealthy(
$"Vector store is enabled but embedding generator with key '{options.EmbeddingGeneratorKey}' is not registered."));
}
return Task.FromResult(HealthCheckResult.Healthy(
$"Vector store is enabled and embedding generator '{options.EmbeddingGeneratorKey}' is available."));
},
tags: new string[] { "ai", "vectordata", "ready" });
return builder;
}
#endif
Available Checks:
- ✅ Vector Store (when UseVectorStore is enabled) - Validates vector store configuration and embedding generator registration
Messaging Health Checks¶
MassTransit¶
MassTransit health checks validate transport and persistence:
#if UseMassTransit
private static void AddMassTransitHealthChecks(
this IHealthChecksBuilder builder,
IServiceCollection services)
{
#if UseMassTransitMongoDBPersistence
MongoClient mongoClient = new MongoClient(
connectionString: OptionsExtensions.MassTransitOptions.MongoDbPersistence.Connection);
builder.AddMongoDb(
clientFactory: (serviceProvider) => mongoClient,
databaseNameFactory: (serviceProvider) =>
OptionsExtensions.MassTransitOptions.MongoDbPersistence.DatabaseName,
name: "ConnectSoft.MicroserviceTemplate MassTransit MongoDb persistence health check",
tags: new string[] { "masstransit persistence mongodb", "ready" });
#endif
#if UseMassTransitRabbitMQTransport
var hostOptions = OptionsExtensions.MassTransitOptions.RabbitMqTransport.RabbitMqTransportHost;
var connectionString = $"amqp://{hostOptions.UserName}:{hostOptions.Password}@{hostOptions.Host}:{hostOptions.Port}/{hostOptions.VirtualHost}";
services.AddSingleton<IConnection>(sp =>
{
var factory = new ConnectionFactory { Uri = new Uri(connectionString) };
return factory.CreateConnectionAsync().GetAwaiter().GetResult();
});
builder.AddRabbitMQ(
name: "ConnectSoft.MicroserviceTemplate MassTransit RabbitMq transport health check",
tags: new string[] { "masstransit transport rabbitmq", "ready" });
#endif
}
#endif
NServiceBus¶
NServiceBus health checks validate transport and persistence databases:
#if UseNServiceBus
private static void AddNServiceBusHealthChecks(
this IHealthChecksBuilder builder,
IConfiguration configuration)
{
#if UseNServiceBusSqlServerTransport
var nsbTransportConnectionString = configuration.GetConnectionString(
NServiceBusExtensions.NServiceBusSqlServerTransportConnectionStringKey);
builder.AddSqlServer(
nsbTransportConnectionString,
healthQuery: "SELECT 1;",
name: "ConnectSoft.MicroserviceTemplate NServiceBus transport database health check",
tags: new string[] { "nservicebus transport sqlserver", "ready" });
#endif
#if UseNServiceBusSQLPersistence
var nsbPersistenceConnectionString = configuration.GetConnectionString(
NServiceBusExtensions.NServiceBusSQLPersistenceConnectionStringKey);
builder.AddSqlServer(
nsbPersistenceConnectionString,
healthQuery: "SELECT 1;",
name: "ConnectSoft.MicroserviceTemplate NServiceBus persistence database health check",
tags: new string[] { "nservicebus persistence sqlserver", "ready" });
#endif
}
#endif
Orleans Health Checks¶
Orleans-specific health checks validate cluster, silo, grain, and storage health:
Registration¶
#if UseOrleans
private static void AddOrleansHealthChecks(this IHealthChecksBuilder builder)
{
builder
.AddCheck<OrleansClusterHealthCheck>(
name: nameof(OrleansClusterHealthCheck),
tags: new[] { "orleans", "cluster" })
.AddCheck<OrleansGrainHealthCheck>(
name: nameof(OrleansGrainHealthCheck),
tags: new[] { "orleans", "grain" })
.AddCheck<OrleansSiloHealthCheck>(
name: nameof(OrleansSiloHealthCheck),
tags: new[] { "orleans", "silo" })
.AddCheck<OrleansStorageHealthCheck>(
name: nameof(OrleansStorageHealthCheck),
tags: new[] { "orleans", "storage" });
}
#endif
Orleans Cluster Health Check¶
Validates cluster membership and silo availability:
public class OrleansClusterHealthCheck(IClusterClient client, ILogger<OrleansClusterHealthCheck> logger)
: IHealthCheck
{
private const string DegradedMessage = " silo(s) unavailable.";
private const string FailedMessage = "Failed cluster status health check.";
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var manager = this.client.GetGrain<IManagementGrain>(0);
try
{
var hosts = await manager.GetHosts().ConfigureAwait(false);
var count = hosts.Values.Count(x => x.IsTerminating() || x == SiloStatus.None);
return count > 0
? HealthCheckResult.Degraded(count + DegradedMessage)
: HealthCheckResult.Healthy();
}
catch (Exception exception)
{
this.logger.LogError(exception: exception, FailedMessage);
return HealthCheckResult.Unhealthy(FailedMessage, exception);
}
}
}
Orleans Silo Health Check¶
Validates silo-level health participants:
public class OrleansSiloHealthCheck(IEnumerable<IHealthCheckParticipant> participants)
: IHealthCheck
{
private static long lastCheckTime = DateTime.UtcNow.ToBinary();
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var thisLastCheckTime = DateTime.FromBinary(
Interlocked.Exchange(ref lastCheckTime, DateTime.UtcNow.ToBinary()));
foreach (var participant in this.participants)
{
if (!participant.CheckHealth(thisLastCheckTime, out var reason))
{
return Task.FromResult(HealthCheckResult.Degraded(reason));
}
}
return Task.FromResult(HealthCheckResult.Healthy());
}
}
Orleans Grain Health Check¶
Pings a known system grain to validate grain responsiveness:
public class OrleansGrainHealthCheck : IHealthCheck
{
private readonly IClusterClient client;
private readonly ILogger<OrleansGrainHealthCheck> logger;
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var grain = this.client.GetGrain<ILocalHealthCheckGrain>(0);
var result = await grain.CheckHealthAsync(cancellationToken).ConfigureAwait(false);
return result
? HealthCheckResult.Healthy()
: HealthCheckResult.Unhealthy("Grain health check failed");
}
catch (Exception ex)
{
this.logger.LogError(ex, "Orleans grain health check failed");
return HealthCheckResult.Unhealthy("Grain health check exception", ex);
}
}
}
Orleans Storage Health Check¶
Validates grain storage backend availability:
public class OrleansStorageHealthCheck : IHealthCheck
{
private readonly IClusterClient client;
private readonly ILogger<OrleansStorageHealthCheck> logger;
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var grain = this.client.GetGrain<IStorageHealthCheckGrain>(Guid.NewGuid());
var result = await grain.CheckStorageHealthAsync(cancellationToken).ConfigureAwait(false);
return result
? HealthCheckResult.Healthy()
: HealthCheckResult.Unhealthy("Storage health check failed");
}
catch (Exception ex)
{
this.logger.LogError(ex, "Orleans storage health check failed");
return HealthCheckResult.Unhealthy("Storage health check exception", ex);
}
}
}
SignalR Health Checks¶
SignalR health checks validate hub connectivity:
#if UseSignalR
private static void AddSignalRHealthChecks(this IHealthChecksBuilder builder)
{
builder.Add(new HealthCheckRegistration(
name: "ConnectSoft.MicroserviceTemplate SignalR health check",
factory: serviceProvider =>
{
// Skip check during warmup
var warmup = serviceProvider.GetRequiredService<StartupWarmupGate>();
if (!warmup.IsReady)
{
return new DelegateHealthCheck(_ =>
Task.FromResult(HealthCheckResult.Healthy("Startup warmup (SignalR check skipped)")));
}
// Get server address
var server = serviceProvider.GetRequiredService<IServer>();
var addresses = server.Features.Get<IServerAddressesFeature>()?.Addresses;
var baseAddr = addresses?.FirstOrDefault(a => a.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
?? addresses?.FirstOrDefault();
if (string.IsNullOrEmpty(baseAddr))
{
baseAddr = Environment.GetEnvironmentVariable("ASPNETCORE_URLS")
?.Split(';', StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault();
}
var opts = serviceProvider.GetService<IOptions<SignalRHealthCheckClientOptions>>()?.Value
?? new SignalRHealthCheckClientOptions();
var url = new Uri(baseAddr, UriKind.Absolute);
var host = (url.Host is "+" or "0.0.0.0" or "::" or "[::]") ? "127.0.0.1" : url.Host;
var hubPath = string.IsNullOrWhiteSpace(opts.HubPath) ? "/webChatHub" : opts.HubPath;
var hubUri = new UriBuilder(url.Scheme, host, url.Port, hubPath.TrimStart('/')).Uri.ToString();
return new SignalRHealthCheck(() =>
{
IHubConnectionBuilder builder = new HubConnectionBuilder();
opts.ConfigureBuilder?.Invoke(serviceProvider, builder);
builder = builder
.WithUrl(hubUri, http => opts.ConfigureHttp?.Invoke(serviceProvider, http))
.WithAutomaticReconnect();
return builder.Build();
});
},
failureStatus: HealthStatus.Unhealthy,
tags: new string[] { "signalr" },
timeout: TimeSpan.FromSeconds(10)));
#if UseRedisSignalRBackplane
builder.AddRedis(
OptionsExtensions.SignalROptions.RedisSignalRBackplaneOptions.ConfigurationString,
name: "ConnectSoft.MicroserviceTemplate SignalR Redis backplane health check",
tags: new string[] { "signalr", "backplane", "redis" });
#endif
}
#endif
gRPC Health Check Protocol¶
The template implements the official gRPC Health Checking Protocol:
Service Registration¶
#if UseGrpc
services.AddGrpcHealthChecks(configure =>
{
// Overall service ("") = checks tagged 'ready'
configure.Services.Map(string.Empty, reg => reg.Tags.Contains("ready", StringComparer.OrdinalIgnoreCase));
// Named mapping that aggregates all checks
configure.Services.Map("all", _ => true);
});
// Map gRPC health service endpoint
endpoints.MapGrpcHealthChecksService();
#endif
Usage¶
Clients can check service health via gRPC:
var client = new Health.HealthClient(channel);
var response = await client.CheckAsync(new HealthCheckRequest { Service = "" });
if (response.Status == HealthCheckResponse.Types.ServingStatus.Serving)
{
// Service is healthy
}
Tag-Based Filtering¶
Health checks use tags to organize and filter checks:
Standard Tags¶
| Tag | Purpose | Used By |
|---|---|---|
ready |
Service readiness | /health endpoint, load balancers |
live |
Process liveness | /alive endpoint, Kubernetes liveness probes |
startup |
Startup warmup | /startup endpoint, Kubernetes startup probes |
orleans |
Orleans-specific checks | Filtering Orleans health status |
storage |
Database/persistence | Filtering storage health |
infra |
Infrastructure components | General infrastructure grouping |
optional |
Non-critical checks | Degraded mode scenarios |
Tag Usage in Checks¶
// Readiness check
builder.AddSqlServer(/* ... */, tags: new string[] { "application sqlserver", "ready" });
// Liveness check
builder.AddCheck("self", () => HealthCheckResult.Healthy(), tags: new string[] { "live" });
// Orleans checks
builder.AddCheck<OrleansClusterHealthCheck>(/* ... */, tags: new[] { "orleans", "cluster" });
// Optional check (won't fail overall health)
builder.AddCheck<ExternalApiHealthCheck>(/* ... */, tags: new[] { "optional", "external" });
Health Checks UI¶
HealthChecks.UI provides a visual dashboard for health monitoring:
Configuration¶
#if HealthCheckUI
private static void AddAndConfigureHealthChecksUI(
this IServiceCollection services,
IConfiguration configuration)
{
HealthChecksUIBuilder healthChecksUIBuilder = services.AddHealthChecksUI(setupOptions =>
{
setupOptions.UseApiEndpointHttpMessageHandler((provider) =>
{
var handler = new HttpClientHandler();
handler.ClientCertificateOptions = ClientCertificateOption.Manual;
handler.ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
return handler;
});
});
#if UseSqlServerHealthCheckUIStorageProvider
string healthChecksUIConnectionString = configuration.GetConnectionString(
"ConnectSoft.MicroserviceTemplateHealthCheckUI");
healthChecksUIBuilder.AddSqlServerStorage(connectionString: healthChecksUIConnectionString);
#endif
}
// Map UI endpoint
endpoints.MapHealthChecksUI(setup =>
{
setup.UIPath = "/" + HealthCheckUIPath;
setup.PageTitle = "ConnectSoft.MicroserviceTemplate Health Checks UI";
});
#endif
Accessing the UI¶
The UI is accessible at /HEALTHCHECK-PATH-UI (configurable via template options).
Health Check Publishers¶
Publishers send health check results to external observability systems:
Seq Publisher¶
Publishes health check results to Seq:
#if UseSeqAsHealthChecksPublisher
private static void AddHealthChecksReportsPublishers(
this IHealthChecksBuilder builder,
HealthChecksOptions healthChecksOptions)
{
builder.AddSeqPublisher(setup =>
{
setup.Endpoint = healthChecksOptions.SeqPublisher.Endpoint;
setup.ApiKey = healthChecksOptions.SeqPublisher.ApiKey;
setup.DefaultInputLevel = HealthChecks.Publisher.Seq.SeqInputLevel.Debug;
});
}
#endif
Application Insights Publisher¶
Publishes to Azure Application Insights:
#if UseApplicationInsightsAsHealthChecksPublisher
builder.AddApplicationInsightsPublisher(
connectionString: healthChecksOptions.ApplicationInsightsPublisher.ConnectionString,
saveDetailedReport: healthChecksOptions.ApplicationInsightsPublisher.SaveDetailedReport,
excludeHealthyReports: healthChecksOptions.ApplicationInsightsPublisher.ExcludeHealthyReports);
#endif
Configuration¶
{
"HealthChecks": {
"SeqPublisher": {
"Endpoint": "https://seq.example.com",
"ApiKey": "optional-api-key"
},
"ApplicationInsightsPublisher": {
"ConnectionString": "InstrumentationKey=...",
"SaveDetailedReport": true,
"ExcludeHealthyReports": false
}
}
}
Custom Health Checks¶
Creating Custom Checks¶
Implement IHealthCheck interface:
public class ExternalApiHealthCheck : IHealthCheck
{
private readonly IHttpClientFactory httpClientFactory;
private readonly ILogger<ExternalApiHealthCheck> logger;
public ExternalApiHealthCheck(
IHttpClientFactory httpClientFactory,
ILogger<ExternalApiHealthCheck> logger)
{
this.httpClientFactory = httpClientFactory;
this.logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var client = this.httpClientFactory.CreateClient("ExternalService");
var response = await client.GetAsync("/status", cancellationToken);
return response.IsSuccessStatusCode
? HealthCheckResult.Healthy()
: HealthCheckResult.Unhealthy($"External API returned {response.StatusCode}");
}
catch (Exception ex)
{
this.logger.LogError(ex, "External API health check failed");
return HealthCheckResult.Unhealthy("External API check exception", ex);
}
}
}
Registering Custom Checks¶
builder.AddCheck<ExternalApiHealthCheck>(
"external-api",
tags: new[] { "ready", "optional", "external" });
Delegate-Based Checks¶
For simple checks, use DelegateHealthCheck:
builder.AddCheck("feature-toggle", () =>
{
return FeatureFlagManager.IsEnabled("Payments")
? HealthCheckResult.Healthy()
: HealthCheckResult.Degraded("Payments feature is disabled");
}, tags: new[] { "optional" });
Configuration Options¶
HealthChecksOptions¶
public sealed class HealthChecksOptions
{
public const string HealthChecksOptionsSectionName = "HealthChecks";
/// <summary>
/// Whether responses from the health check middleware can be cached.
/// </summary>
[Required]
required public bool AllowCachingResponses { get; set; }
#if UseSeqAsHealthChecksPublisher
[Required]
[ValidateObjectMembers]
required public HealthChecksSeqPublisherOptions SeqPublisher { get; set; }
#endif
#if UseApplicationInsightsAsHealthChecksPublisher
[Required]
[ValidateObjectMembers]
required public HealthChecksApplicationInsightsPublisherOptions ApplicationInsightsPublisher { get; set; }
#endif
}
appsettings.json Example¶
{
"HealthChecks": {
"AllowCachingResponses": false,
"SeqPublisher": {
"Endpoint": "https://seq.example.com",
"ApiKey": "optional-key"
},
"ApplicationInsightsPublisher": {
"ConnectionString": "InstrumentationKey=...",
"SaveDetailedReport": true,
"ExcludeHealthyReports": false
}
}
}
Kubernetes Integration¶
Deployment Configuration¶
Health checks integrate with Kubernetes probes:
apiVersion: apps/v1
kind: Deployment
metadata:
name: microservice
spec:
template:
spec:
containers:
- name: microservice
image: microservice:latest
livenessProbe:
httpGet:
path: /alive
port: 80
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /startup
port: 80
initialDelaySeconds: 0
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 30
Probe Behavior¶
- Liveness Probe (
/alive): If fails, Kubernetes restarts the pod - Readiness Probe (
/health): If fails, Kubernetes removes pod from service endpoints - Startup Probe (
/startup): Gives slow-starting services time to initialize
Testing¶
Acceptance Tests¶
Health checks are validated via Reqnroll feature tests:
[Binding]
public sealed class TestHealthCheckEndpointStepBinding
{
[When(@"I check the health of the application")]
public async Task WhenICheckTheHealthOfTheApplication()
{
using HttpClient? httpClient = BeforeAfterTestRunHooks.ServerInstance?.CreateClient();
this.healthCheckHttpResponse = await WaitForHealthyAsync(
httpClient,
"HEALTHCHECK-PATH",
ReadyTimeout,
PollInterval).ConfigureAwait(false);
}
[Then(@"I should receive a 200 OK response")]
public async Task ThenIShouldReceiveAOKResponse()
{
Assert.IsNotNull(this.healthCheckHttpResponse);
string content = await this.healthCheckHttpResponse.Content.ReadAsStringAsync();
Assert.IsTrue(this.healthCheckHttpResponse.IsSuccessStatusCode, "Response: " + content);
Assert.AreEqual(HttpStatusCode.OK, this.healthCheckHttpResponse.StatusCode);
}
private static async Task<HttpResponseMessage> WaitForHealthyAsync(
HttpClient client,
string path,
TimeSpan timeout,
TimeSpan pollInterval)
{
var deadline = DateTime.UtcNow + timeout;
HttpResponseMessage? last = null;
while (DateTime.UtcNow < deadline)
{
last?.Dispose();
last = await client.GetAsync(path).ConfigureAwait(false);
var body = await last.Content.ReadAsStringAsync().ConfigureAwait(false);
if (last.IsSuccessStatusCode && IsHealthy(body))
{
return last;
}
await Task.Delay(pollInterval).ConfigureAwait(false);
}
last?.Dispose();
throw new TimeoutException($"Health check did not become healthy within {timeout}");
}
private static bool IsHealthy(string jsonBody)
{
// Parse JSON and check status field
// Implementation details...
return jsonBody.Contains("\"status\":\"Healthy\"", StringComparison.OrdinalIgnoreCase);
}
}
gRPC Health Check Tests¶
[Binding]
public sealed class TestGrpcHealthCheckProtocolStepDefinition
{
[When(@"the gRPC client calls Health.Check")]
public async Task WhenTheGrpcClientCallsHealthCheck()
{
var response = await this.grpcHealthClient.CheckAsync(
new HealthCheckRequest { Service = "" });
this.grpcHealthResponse = response;
}
[Then(@"the response status should be SERVING")]
public void ThenTheResponseStatusShouldBeServing()
{
this.grpcHealthResponse.Status.Should().Be(
HealthCheckResponse.Types.ServingStatus.Serving);
}
}
Security Considerations¶
Endpoint Protection¶
Health check endpoints should be protected appropriately:
// Protect /health endpoint (internal diagnostics)
app.MapHealthChecks("/health", new HealthCheckOptions { /* ... */ })
.RequireAuthorization("HealthChecksPolicy");
// Keep /alive public (Kubernetes needs access)
app.MapHealthChecks("/alive", new HealthCheckOptions { /* ... */ });
Best Practices¶
| Practice | Reason |
|---|---|
Restrict /health to internal networks |
Prevents infrastructure information disclosure |
Keep /alive public or IP-whitelisted |
Required for orchestrator liveness probes |
| Sanitize error messages | Avoid exposing sensitive details in health responses |
Use authentication for /health-ui |
Dashboard may contain sensitive operational data |
| Avoid connection strings in responses | Prevent credential exposure |
| Disable stack traces in production | Prevent code path disclosure |
Secure Response Example¶
return HealthCheckResult.Unhealthy(
description: "Database connection failed"); // Generic, no details
// Avoid:
return HealthCheckResult.Unhealthy(
description: $"Connection failed: Server=db.example.com;User=admin;Password=secret");
Best Practices¶
Do's¶
- Use Appropriate Tags
- Tag checks as
readyfor traffic routing decisions - Tag checks as
livefor process-level health -
Use
optionalfor non-critical dependencies -
Keep Checks Fast
- Health checks should complete in < 1 second
- Use simple queries (e.g.,
SELECT 1) -
Avoid expensive operations
-
Handle Failures Gracefully
- Catch and log exceptions
- Return appropriate health status
-
Don't throw exceptions from health checks
-
Monitor Health Check Metrics
- Track health check duration
- Alert on repeated failures
-
Correlate health failures with application logs
-
Use Conditional Registration
- Only register checks for enabled features
- Use preprocessor directives for optional features
- Avoid unnecessary check overhead
Don'ts¶
-
Don't Use Expensive Queries
-
Don't Expose Sensitive Data
-
Don't Block Health Checks
-
Don't Register Unnecessary Checks
Complete Health Check Reference¶
System-Level Health Checks¶
| Check | Condition | Tags | Description |
|---|---|---|---|
| Disk Storage | Always | diskstorage |
Validates available disk space (5 GB minimum) |
| Process Allocated Memory | Always | allocatedmemory |
Monitors process memory allocation (1024 MB max) |
| Private Memory | Windows only | privatememory |
Windows-specific private memory check |
| Working Set | Windows only | workingset |
Windows-specific working set check |
| Virtual Memory | Windows only | virtualmemory |
Windows-specific virtual memory check |
| Cross-Platform Memory | Linux/Mac only | memory |
Cross-platform memory check (1 GB threshold) |
| Resource Utilization | ResourceMonitoring |
resource utilization |
CPU and memory utilization thresholds (Windows only) |
| Self | Always | live |
Basic liveness check |
| Startup Gate | Always | startup |
Validates startup warmup completion |
Database Health Checks¶
| Check | Condition | Tags | Description |
|---|---|---|---|
| SQL Server (NHibernate) | UseNHibernate |
application sqlserver, ready |
Validates SQL Server database connectivity |
| MongoDB | UseMongoDb |
application mongodb, ready |
Validates MongoDB connectivity |
| Redis | DistributedCacheRedis |
application redis, ready |
Validates Redis connectivity |
Messaging Health Checks¶
| Check | Condition | Tags | Description |
|---|---|---|---|
| NServiceBus SQL Transport | UseNServiceBus + UseNServiceBusSqlServerTransport |
nservicebus transport sqlserver, ready |
Validates NServiceBus SQL Server transport database |
| NServiceBus SQL Persistence | UseNServiceBus + UseNServiceBusSQLPersistence |
nservicebus persistence sqlserver, ready |
Validates NServiceBus SQL Server persistence database |
| MassTransit MongoDB Persistence | UseMassTransit + UseMassTransitMongoDBPersistence |
masstransit persistence mongodb, ready |
Validates MassTransit MongoDB persistence |
| MassTransit RabbitMQ Transport | UseMassTransit + UseMassTransitRabbitMQTransport |
masstransit transport rabbitmq, ready |
Validates MassTransit RabbitMQ transport connectivity |
Actor Model Health Checks (Orleans)¶
| Check | Condition | Tags | Description |
|---|---|---|---|
| Orleans Cluster | UseOrleans |
orleans, cluster |
Validates Orleans cluster membership and silo availability |
| Orleans Grain | UseOrleans |
orleans, grain |
Validates grain responsiveness |
| Orleans Silo | UseOrleans |
orleans, silo |
Validates silo-level health participants |
| Orleans Storage | UseOrleans |
orleans, storage |
Validates grain storage backend availability |
| Orleans Azure Table Storage Clustering | UseOrleans + Azure Table Storage clustering |
orleans, clustering, azure-table-storage, ready |
Validates Azure Table Storage clustering connectivity |
| Orleans ADO.NET Clustering | UseOrleans + ADO.NET clustering |
orleans, clustering, adonet, ready |
Validates ADO.NET clustering database connectivity |
| Orleans Redis Clustering | UseOrleans + Redis clustering |
orleans, clustering, redis, ready |
Validates Redis clustering connectivity |
| Orleans Azure Table Storage Transactional State | UseOrleans + Azure Table Storage transactional state |
orleans, transactional-state, azure-table-storage, ready |
Validates Azure Table Storage transactional state storage |
Service Health Checks¶
| Check | Condition | Tags | Description |
|---|---|---|---|
| SignalR Hub | UseSignalR |
signalr |
Validates SignalR hub connectivity |
| SignalR Redis Backplane | UseSignalR + UseRedisSignalRBackplane |
signalr, backplane, redis |
Validates SignalR Redis backplane connectivity |
| Hangfire | UseHangFire |
application hangfire |
Validates Hangfire job processing availability |
| gRPC Health Service | UseGrpc |
ready |
Official gRPC health checking protocol |
AI Health Checks¶
| Check | Condition | Tags | Description |
|---|---|---|---|
| OpenAI Chat Client | UseMicrosoftExtensionsAI + UseOpenAI |
ai, chat, openai, ready |
Validates OpenAI chat client registration |
| Azure OpenAI Chat Client | UseMicrosoftExtensionsAI + UseAzureOpenAI |
ai, chat, azureopenai, ready |
Validates Azure OpenAI chat client registration |
| Ollama Chat Client | UseMicrosoftExtensionsAI + UseOllama |
ai, chat, ollama, ready |
Validates Ollama chat client registration |
| Azure AI Inference Chat Client | UseMicrosoftExtensionsAI + UseMicrosoftExtensionsAIAzureAIInferenceProvider |
ai, chat, azureaiinference, ready |
Validates Azure AI Inference chat client registration |
| OpenAI Embedding Generator | UseMicrosoftExtensionsAIEmbedding + UseMicrosoftExtensionsAIOpenAIEmbeddingProvider |
ai, embedding, openai, ready |
Validates OpenAI embedding generator registration |
| Azure OpenAI Embedding Generator | UseMicrosoftExtensionsAIEmbedding + UseMicrosoftExtensionsAIAzureOpenAIEmbeddingProvider |
ai, embedding, azureopenai, ready |
Validates Azure OpenAI embedding generator registration |
| Azure AI Inference Embedding Generator | UseMicrosoftExtensionsAIEmbedding + UseMicrosoftExtensionsAIAzureAIInferenceEmbeddingProvider |
ai, embedding, azureaiinference, ready |
Validates Azure AI Inference embedding generator registration |
| Ollama Embedding Generator | UseMicrosoftExtensionsAIEmbedding + UseMicrosoftExtensionsAIOllamaEmbeddingProvider |
ai, embedding, ollama, ready |
Validates Ollama embedding generator registration |
| Vector Store | UseVectorStore |
ai, vectordata, ready |
Validates vector store configuration and embedding generator availability |
Summary¶
Health Checks in the ConnectSoft Microservice Template provide:
- ✅ Standardized Endpoints:
/health,/alive,/startupwith consistent JSON responses - ✅ Comprehensive Coverage: System, database, messaging, actor model, AI services, and service checks
- ✅ Tag-Based Filtering: Organize checks by purpose (ready, live, startup, optional)
- ✅ Orchestrator Integration: Kubernetes probes, load balancer health validation
- ✅ Observability: Seq, Application Insights, and OpenTelemetry publishers
- ✅ Visual Dashboard: HealthChecks.UI for development and operations
- ✅ gRPC Protocol: Official gRPC health checking protocol support
- ✅ AI Service Monitoring: Health checks for chat completions, embedding generators, and vector stores
- ✅ Customizable: Easy to add custom checks for domain-specific scenarios
- ✅ Secure: Configurable endpoint protection and sanitized responses
- ✅ Testable: Acceptance tests validate health check behavior
By following these patterns, health checks enable reliable, observable, and maintainable microservice operations, ensuring services remain available and dependencies are properly monitored across the entire system lifecycle.
Related Documentation¶
- Startup and Warmup: Startup warmup patterns and mechanisms for graceful application initialization
- Application Model: Application model and service registration
- Kubernetes: Kubernetes deployment patterns and probe configuration
- Observability: Comprehensive observability patterns