src/Service/HealthCheck/HealthCheckHelper.cs (219 lines of code) (raw):
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Azure.DataApiBuilder.Config.HealthCheck;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.Authorization;
using Azure.DataApiBuilder.Product;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
namespace Azure.DataApiBuilder.Service.HealthCheck
{
/// <summary>
/// Creates a JSON response of health report by executing the Datasource, Rest and Graphql endpoints.
/// Checks the response time with the threshold given to formulate the comprehensive report.
/// </summary>
public class HealthCheckHelper
{
// Dependencies
private ILogger<HealthCheckHelper> _logger;
private HttpUtilities _httpUtility;
private string _incomingRoleHeader = string.Empty;
private string _incomingRoleToken = string.Empty;
private const string TIME_EXCEEDED_ERROR_MESSAGE = "The threshold for executing the request has exceeded.";
/// <summary>
/// Constructor to inject the logger and HttpUtility class.
/// </summary>
/// <param name="logger">Logger to track the log statements.</param>
/// <param name="httpUtility">HttpUtility to call methods from the internal class.</param>
public HealthCheckHelper(ILogger<HealthCheckHelper> logger, HttpUtilities httpUtility)
{
_logger = logger;
_httpUtility = httpUtility;
}
/// <summary>
/// GetHealthCheckResponse is the main function which fetches the HttpContext and then creates the comprehensive health check report.
/// Serializes the report to JSON and returns the response.
/// </summary>
/// <param name="context">HttpContext</param>
/// <param name="runtimeConfig">RuntimeConfig</param>
/// <returns>This function returns the comprehensive health report after calculating the response time of each datasource, rest and graphql health queries.</returns>
public ComprehensiveHealthCheckReport GetHealthCheckResponse(HttpContext context, RuntimeConfig runtimeConfig)
{
// Create a JSON response for the comprehensive health check endpoint using the provided basic health report.
// If the response has already been created, it will be reused.
_httpUtility.ConfigureApiRoute(context);
LogTrace("Comprehensive Health check is enabled in the runtime configuration.");
ComprehensiveHealthCheckReport ComprehensiveHealthCheckReport = new();
UpdateVersionAndAppName(ref ComprehensiveHealthCheckReport);
UpdateDabConfigurationDetails(ref ComprehensiveHealthCheckReport, runtimeConfig);
UpdateHealthCheckDetails(ref ComprehensiveHealthCheckReport, runtimeConfig);
UpdateOverallHealthStatus(ref ComprehensiveHealthCheckReport);
return ComprehensiveHealthCheckReport;
}
// Updates the incoming role header with the appropriate value from the request headers.
public void StoreIncomingRoleHeader(HttpContext httpContext)
{
StringValues clientRoleHeader = httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER];
StringValues clientTokenHeader = httpContext.Request.Headers[AuthenticationOptions.CLIENT_PRINCIPAL_HEADER];
if (clientRoleHeader.Count > 1 || clientTokenHeader.Count > 1)
{
throw new ArgumentException("Multiple values for the client role or token header are not allowed.");
}
// Role Header is not present in the request, set it to anonymous.
if (clientRoleHeader.Count == 1)
{
_incomingRoleHeader = clientRoleHeader.ToString().ToLowerInvariant();
}
if (clientTokenHeader.Count == 1)
{
_incomingRoleToken = clientTokenHeader.ToString();
}
}
/// <summary>
/// Checks if the incoming request is allowed to access the health check endpoint.
/// Anonymous requests are only allowed in Development Mode.
/// </summary>
/// <param name="httpContext">HttpContext to get the headers.</param>
/// <param name="hostMode">Compare with the HostMode of DAB</param>
/// <param name="allowedRoles">AllowedRoles in the Runtime.Health config</param>
/// <returns></returns>
public bool IsUserAllowedToAccessHealthCheck(HttpContext httpContext, bool isDevelopmentMode, HashSet<string> allowedRoles)
{
if (allowedRoles == null || allowedRoles.Count == 0)
{
// When allowedRoles is null or empty, all roles are allowed if Mode = Development.
return isDevelopmentMode;
}
return allowedRoles.Contains(_incomingRoleHeader);
}
// Updates the overall status by comparing all the internal HealthStatuses in the response.
private static void UpdateOverallHealthStatus(ref ComprehensiveHealthCheckReport comprehensiveHealthCheckReport)
{
if (comprehensiveHealthCheckReport.Checks == null)
{
comprehensiveHealthCheckReport.Status = HealthStatus.Healthy;
return;
}
comprehensiveHealthCheckReport.Status = comprehensiveHealthCheckReport.Checks?.Any(check => check.Status == HealthStatus.Unhealthy) == true
? HealthStatus.Unhealthy
: HealthStatus.Healthy;
}
// Updates the AppName and Version for the Health report.
private static void UpdateVersionAndAppName(ref ComprehensiveHealthCheckReport response)
{
// Update the version and app name to the response.
response.Version = ProductInfo.GetProductVersion();
response.AppName = ProductInfo.GetDataApiBuilderUserAgent();
}
// Updates the DAB configuration details coming from RuntimeConfig for the Health report.
private static void UpdateDabConfigurationDetails(ref ComprehensiveHealthCheckReport ComprehensiveHealthCheckReport, RuntimeConfig runtimeConfig)
{
ComprehensiveHealthCheckReport.ConfigurationDetails = new ConfigurationDetails
{
Rest = runtimeConfig.IsRestEnabled,
GraphQL = runtimeConfig.IsGraphQLEnabled,
Caching = runtimeConfig.IsCachingEnabled,
Telemetry = runtimeConfig?.Runtime?.Telemetry != null,
Mode = runtimeConfig?.Runtime?.Host?.Mode ?? HostMode.Production, // Modify to runtimeConfig.HostMode in Roles PR
};
}
// Main function to internally call for data source and entities health check.
private void UpdateHealthCheckDetails(ref ComprehensiveHealthCheckReport ComprehensiveHealthCheckReport, RuntimeConfig runtimeConfig)
{
ComprehensiveHealthCheckReport.Checks = new List<HealthCheckResultEntry>();
UpdateDataSourceHealthCheckResults(ref ComprehensiveHealthCheckReport, runtimeConfig);
UpdateEntityHealthCheckResults(ref ComprehensiveHealthCheckReport, runtimeConfig);
}
// Updates the DataSource Health Check Results in the response.
private void UpdateDataSourceHealthCheckResults(ref ComprehensiveHealthCheckReport ComprehensiveHealthCheckReport, RuntimeConfig runtimeConfig)
{
if (ComprehensiveHealthCheckReport.Checks != null && runtimeConfig.DataSource.IsDatasourceHealthEnabled)
{
string query = Utilities.GetDatSourceQuery(runtimeConfig.DataSource.DatabaseType);
(int, string?) response = ExecuteDatasourceQueryCheck(query, runtimeConfig.DataSource.ConnectionString);
bool isResponseTimeWithinThreshold = response.Item1 >= 0 && response.Item1 < runtimeConfig.DataSource.DatasourceThresholdMs;
// Add DataSource Health Check Results
ComprehensiveHealthCheckReport.Checks.Add(new HealthCheckResultEntry
{
Name = runtimeConfig?.DataSource?.Health?.Name ?? runtimeConfig?.DataSource?.DatabaseType.ToString(),
ResponseTimeData = new ResponseTimeData
{
ResponseTimeMs = response.Item1,
ThresholdMs = runtimeConfig?.DataSource.DatasourceThresholdMs
},
Exception = !isResponseTimeWithinThreshold ? TIME_EXCEEDED_ERROR_MESSAGE : response.Item2,
Tags = [HealthCheckConstants.DATASOURCE],
Status = isResponseTimeWithinThreshold ? HealthStatus.Healthy : HealthStatus.Unhealthy
});
}
}
// Executes the DB Query and keeps track of the response time and error message.
private (int, string?) ExecuteDatasourceQueryCheck(string query, string connectionString)
{
string? errorMessage = null;
if (!string.IsNullOrEmpty(query) && !string.IsNullOrEmpty(connectionString))
{
Stopwatch stopwatch = new();
stopwatch.Start();
errorMessage = _httpUtility.ExecuteDbQuery(query, connectionString);
stopwatch.Stop();
return string.IsNullOrEmpty(errorMessage) ? ((int)stopwatch.ElapsedMilliseconds, errorMessage) : (HealthCheckConstants.ERROR_RESPONSE_TIME_MS, errorMessage);
}
return (HealthCheckConstants.ERROR_RESPONSE_TIME_MS, errorMessage);
}
// Updates the Entity Health Check Results in the response.
// Goes through the entities one by one and executes the rest and graphql checks (if enabled).
private void UpdateEntityHealthCheckResults(ref ComprehensiveHealthCheckReport ComprehensiveHealthCheckReport, RuntimeConfig runtimeConfig)
{
if (runtimeConfig?.Entities != null && runtimeConfig.Entities.Entities.Any())
{
foreach (KeyValuePair<string, Entity> Entity in runtimeConfig.Entities.Entities)
{
if (Entity.Value.IsEntityHealthEnabled)
{
PopulateEntityHealth(ComprehensiveHealthCheckReport, Entity, runtimeConfig);
}
}
}
}
// Populates the Entity Health Check Results in the response for a particular entity.
// Checks for Rest enabled and executes the rest query.
// Checks for GraphQL enabled and executes the graphql query.
private void PopulateEntityHealth(ComprehensiveHealthCheckReport ComprehensiveHealthCheckReport, KeyValuePair<string, Entity> entity, RuntimeConfig runtimeConfig)
{
// Global Rest and GraphQL Runtime Options
RuntimeOptions? runtimeOptions = runtimeConfig.Runtime;
string entityKeyName = entity.Key;
// Entity Health Check and Runtime Options
Entity entityValue = entity.Value;
if (runtimeOptions != null && entityValue != null)
{
if (runtimeOptions.IsRestEnabled && entityValue.IsRestEnabled)
{
ComprehensiveHealthCheckReport.Checks ??= new List<HealthCheckResultEntry>();
// In case of REST API, use the path specified in [entity.path] (if present).
// The path is trimmed to remove the leading '/' character.
// If the path is not present, use the entity key name as the path.
string entityPath = entityValue.Rest.Path != null ? entityValue.Rest.Path.TrimStart('/') : entityKeyName;
(int, string?) response = ExecuteRestEntityQuery(runtimeConfig.RestPath, entityPath, entityValue.EntityFirst);
bool isResponseTimeWithinThreshold = response.Item1 >= 0 && response.Item1 < entityValue.EntityThresholdMs;
// Add Entity Health Check Results
ComprehensiveHealthCheckReport.Checks.Add(new HealthCheckResultEntry
{
Name = entityKeyName,
ResponseTimeData = new ResponseTimeData
{
ResponseTimeMs = response.Item1,
ThresholdMs = entityValue.EntityThresholdMs
},
Tags = [HealthCheckConstants.REST, HealthCheckConstants.ENDPOINT],
Exception = response.Item2 ?? (!isResponseTimeWithinThreshold ? TIME_EXCEEDED_ERROR_MESSAGE : null),
Status = isResponseTimeWithinThreshold ? HealthStatus.Healthy : HealthStatus.Unhealthy
});
}
if (runtimeOptions.IsGraphQLEnabled && entityValue.IsGraphQLEnabled)
{
ComprehensiveHealthCheckReport.Checks ??= new List<HealthCheckResultEntry>();
(int, string?) response = ExecuteGraphQLEntityQuery(runtimeConfig.GraphQLPath, entityValue, entityKeyName);
bool isResponseTimeWithinThreshold = response.Item1 >= 0 && response.Item1 < entityValue.EntityThresholdMs;
ComprehensiveHealthCheckReport.Checks.Add(new HealthCheckResultEntry
{
Name = entityKeyName,
ResponseTimeData = new ResponseTimeData
{
ResponseTimeMs = response.Item1,
ThresholdMs = entityValue.EntityThresholdMs
},
Tags = [HealthCheckConstants.GRAPHQL, HealthCheckConstants.ENDPOINT],
Exception = response.Item2 ?? (!isResponseTimeWithinThreshold ? TIME_EXCEEDED_ERROR_MESSAGE : null),
Status = isResponseTimeWithinThreshold ? HealthStatus.Healthy : HealthStatus.Unhealthy
});
}
}
}
// Executes the Rest Entity Query and keeps track of the response time and error message.
private (int, string?) ExecuteRestEntityQuery(string restUriSuffix, string entityName, int first)
{
string? errorMessage = null;
if (!string.IsNullOrEmpty(entityName))
{
Stopwatch stopwatch = new();
stopwatch.Start();
errorMessage = _httpUtility.ExecuteRestQuery(restUriSuffix, entityName, first, _incomingRoleHeader, _incomingRoleToken);
stopwatch.Stop();
return string.IsNullOrEmpty(errorMessage) ? ((int)stopwatch.ElapsedMilliseconds, errorMessage) : (HealthCheckConstants.ERROR_RESPONSE_TIME_MS, errorMessage);
}
return (HealthCheckConstants.ERROR_RESPONSE_TIME_MS, errorMessage);
}
// Executes the GraphQL Entity Query and keeps track of the response time and error message.
private (int, string?) ExecuteGraphQLEntityQuery(string graphqlUriSuffix, Entity entity, string entityName)
{
string? errorMessage = null;
if (entity != null)
{
Stopwatch stopwatch = new();
stopwatch.Start();
errorMessage = _httpUtility.ExecuteGraphQLQuery(graphqlUriSuffix, entityName, entity, _incomingRoleHeader, _incomingRoleToken);
stopwatch.Stop();
return string.IsNullOrEmpty(errorMessage) ? ((int)stopwatch.ElapsedMilliseconds, errorMessage) : (HealthCheckConstants.ERROR_RESPONSE_TIME_MS, errorMessage);
}
return (HealthCheckConstants.ERROR_RESPONSE_TIME_MS, errorMessage);
}
// <summary>
/// Logs a trace message if a logger is present and the logger is enabled for trace events.
/// </summary>
/// <param name="message">Message to emit.</param>
private void LogTrace(string message)
{
_logger.LogTrace(message);
}
}
}