diff --git a/src/Config/Converters/DataSourceConverterFactory.cs b/src/Config/Converters/DataSourceConverterFactory.cs index b1d0f60f5c..10e838bfb4 100644 --- a/src/Config/Converters/DataSourceConverterFactory.cs +++ b/src/Config/Converters/DataSourceConverterFactory.cs @@ -47,7 +47,7 @@ public DataSourceConverter(bool replaceEnvVar) public override DataSource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - DataSource dataSource = new(DatabaseType.MSSQL, string.Empty, null); + DataSource dataSource = new(DatabaseType.MSSQL, string.Empty, Options: null, Health: null); if (reader.TokenType is JsonTokenType.StartObject) { @@ -114,6 +114,18 @@ public DataSourceConverter(bool replaceEnvVar) dataSource = dataSource with { Options = optionsDict }; break; } + case "health": + if (reader.TokenType == JsonTokenType.Null) + { + dataSource = dataSource with { Health = null }; + } + else + { + DabHealthCheckConfig health = JsonSerializer.Deserialize(ref reader, options) ?? new(); + dataSource = dataSource with { Health = health }; + } + + break; default: throw new JsonException($"Unexpected property {propertyName} while deserializing DataSource."); } diff --git a/src/Config/ObjectModel/DataSource.cs b/src/Config/ObjectModel/DataSource.cs index 95b6b1a04b..aff86b42fd 100644 --- a/src/Config/ObjectModel/DataSource.cs +++ b/src/Config/ObjectModel/DataSource.cs @@ -12,7 +12,12 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; /// Type of database to use. /// Connection string to access the database. /// Custom options for the specific database. If there are no options, this could be null. -public record DataSource(DatabaseType DatabaseType, string ConnectionString, Dictionary? Options) +/// Health check configuration for the database. In case null, follow old format of health check. +public record DataSource( + DatabaseType DatabaseType, + string ConnectionString, + Dictionary? Options, + DabHealthCheckConfig? Health = null) { /// /// Converts the Options dictionary into a typed options object. diff --git a/src/Config/ObjectModel/Entity.cs b/src/Config/ObjectModel/Entity.cs index ec92a1f5a4..4bebb01dd0 100644 --- a/src/Config/ObjectModel/Entity.cs +++ b/src/Config/ObjectModel/Entity.cs @@ -19,6 +19,7 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; /// Defines mappings between database fields and GraphQL and REST fields. /// Defines whether to allow caching for a read operation's response and /// how long that response should be valid in the cache. +/// Defines the health check configuration for the entity. public record Entity { public const string PROPERTY_PATH = "path"; @@ -31,6 +32,7 @@ public record Entity public Dictionary? Mappings { get; init; } public Dictionary? Relationships { get; init; } public EntityCacheOptions? Cache { get; init; } + public DabHealthCheckConfig? Health { get; init; } [JsonIgnore] public bool IsLinkingEntity { get; init; } @@ -44,6 +46,7 @@ public Entity( Dictionary? Mappings, Dictionary? Relationships, EntityCacheOptions? Cache = null, + DabHealthCheckConfig? Health = null, bool IsLinkingEntity = false) { this.Source = Source; @@ -53,6 +56,7 @@ public Entity( this.Mappings = Mappings; this.Relationships = Relationships; this.Cache = Cache; + this.Health = Health; this.IsLinkingEntity = IsLinkingEntity; } diff --git a/src/Config/ObjectModel/GraphQLSchemaModel.cs b/src/Config/ObjectModel/GraphQLSchemaModel.cs new file mode 100644 index 0000000000..a9e0ca110c --- /dev/null +++ b/src/Config/ObjectModel/GraphQLSchemaModel.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel.GraphQL; + +public class GraphQLSchemaMode +{ + [JsonPropertyName("data")] + public required Data Data { get; set; } +} + +public class Data +{ + [JsonPropertyName("__schema")] + public required Schema Schema { get; set; } +} + +public class Schema +{ + [JsonPropertyName("types")] + public required Types[] Types { get; set; } +} + +public class Types +{ + [JsonPropertyName("kind")] + public required string Kind { get; set; } + [JsonPropertyName("name")] + public required string Name { get; set; } + [JsonPropertyName("fields")] + public required Field[] Fields { get; set; } +} + +public class Field +{ + [JsonPropertyName("name")] + public required string Name { get; set; } + [JsonPropertyName("type")] + public required Type Type { get; set; } +} + +public class Type +{ + [JsonPropertyName("kind")] + public required string Kind { get; set; } + [JsonPropertyName("ofType")] + public Type? OfType { get; set; } +} + +/* +{ + "data": { + "__schema": { + "types": [ + { + "kind": "OBJECT", + "name": "Publisher", + "fields": [ + { + "name": "id" + }, + { + "name": "name" + } + ] + } + ] + } + } +} +*/ diff --git a/src/Config/ObjectModel/HealthCheck/DabConfigurationDetails.cs b/src/Config/ObjectModel/HealthCheck/DabConfigurationDetails.cs new file mode 100644 index 0000000000..0e9a00b795 --- /dev/null +++ b/src/Config/ObjectModel/HealthCheck/DabConfigurationDetails.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Config.ObjectModel +{ + /// + /// The health report of the DAB Enigne. + /// + public record DabConfigurationDetails + { + public bool Rest { get; init; } + public bool GraphQL { get; init; } + public bool Caching { get; init; } + public bool Telemetry { get; init; } + public HostMode Mode { get; init; } + } +} diff --git a/src/Config/ObjectModel/HealthCheck/DabHealthCheckConfig.cs b/src/Config/ObjectModel/HealthCheck/DabHealthCheckConfig.cs new file mode 100644 index 0000000000..1315fe416f --- /dev/null +++ b/src/Config/ObjectModel/HealthCheck/DabHealthCheckConfig.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +public record DabHealthCheckConfig +{ + public bool Enabled { get; set; } // Default value: false + + // The moniker or simple name of the data source to be checked. + // Required when there is a multiple data source scenario. + // TODO: Add validity support for when multiple data sources + public string? Moniker { get; set; } + + // The query to be executed to check the health of the data source. + // "query: "SELECT TOP 1 1" + public string? Query { get; set; } + + // This provides the ability to specify the 'x' first rows to be returned by the query. + // Default is 1 + public int? First { get; set; } = 1; + + // The expected milliseconds the query took to be considered healthy. + // (Default: 10000ms) + [JsonPropertyName("threshold-ms")] + public int? ThresholdMs { get; set; } + + // TODO: Add support for "roles": ["anonymous", "authenticated"] + // public string[] Roles { get; set; } = new string[] { "*" }; + // TODO: Add support for parallel stream to run the health check query + // public int MaxDop { get; set; } = 1; // Parallelized streams to run Health Check (Default: 1) +} diff --git a/src/Config/ObjectModel/HealthCheck/DabHealthCheckReport.cs b/src/Config/ObjectModel/HealthCheck/DabHealthCheckReport.cs new file mode 100644 index 0000000000..5242b9cc0f --- /dev/null +++ b/src/Config/ObjectModel/HealthCheck/DabHealthCheckReport.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum HealthStatus + { + Healthy, + Unhealthy + } + + /// + /// The health report of the DAB Engine. + /// + public record DabHealthCheckReport + { + /// + /// The health status of the service. + /// + public HealthStatus HealthStatus { get; init; } + + /// + /// The version of the service. + /// + public string? Version { get; set; } + + /// + /// The name of the dab service. + /// + public string? AppName { get; set; } + + /// + /// The configuration details of the dab service. + /// + public DabConfigurationDetails? DabConfigurationDetails { get; set; } + + /// + /// The health check results of the dab service. + /// + public DabHealthCheckResults? HealthCheckResults { get; set; } + + /// + /// The health check results of the dab service in output format. + /// + public DabHealthCheckResultFormat? Checks { get; set; } + } + + public class DabHealthCheckResultFormat + { + public Dictionary? DataSourceHealthCheckResults { get; set; } + + public Dictionary>? EntityHealthCheckResults { get; set; } + } +} diff --git a/src/Config/ObjectModel/HealthCheck/DabHealthCheckResults.cs b/src/Config/ObjectModel/HealthCheck/DabHealthCheckResults.cs new file mode 100644 index 0000000000..e049e1b89a --- /dev/null +++ b/src/Config/ObjectModel/HealthCheck/DabHealthCheckResults.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Config.ObjectModel +{ + /// + /// The health report of the DAB Engine. + /// + public record DabHealthCheckResults + { + public List? DataSourceHealthCheckResults { get; init; } + public List? EntityHealthCheckResults { get; init; } + } + + public class HealthCheckResultEntry + { + public string? Name { get; set; } + public string? Description { get; init; } + } + + public class HealthCheckDetailsResultEntry : HealthCheckResultEntry + { + public HealthStatus HealthStatus { get; init; } + public string? Exception { get; init; } + public ResponseTimeData? ResponseTimeData { get; init; } + } + + public class HealthCheckEntityResultEntry : HealthCheckResultEntry + { + public required Dictionary EntityHealthCheckResults { get; init; } + } + + public class ResponseTimeData + { + public int? ResponseTimeMs { get; init; } + public int? MaxAllowedResponseTimeMs { get; init; } + } +} diff --git a/src/Config/ObjectModel/HostMode.cs b/src/Config/ObjectModel/HostMode.cs index 7a795cf5b2..3fafcedd3f 100644 --- a/src/Config/ObjectModel/HostMode.cs +++ b/src/Config/ObjectModel/HostMode.cs @@ -1,8 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.Json.Serialization; + namespace Azure.DataApiBuilder.Config.ObjectModel; +[JsonConverter(typeof(JsonStringEnumConverter))] public enum HostMode { Development, diff --git a/src/Config/ObjectModel/RuntimeOptions.cs b/src/Config/ObjectModel/RuntimeOptions.cs index 321f72d5d0..cefcf00165 100644 --- a/src/Config/ObjectModel/RuntimeOptions.cs +++ b/src/Config/ObjectModel/RuntimeOptions.cs @@ -15,6 +15,7 @@ public record RuntimeOptions public TelemetryOptions? Telemetry { get; init; } public EntityCacheOptions? Cache { get; init; } public PaginationOptions? Pagination { get; init; } + public DabHealthCheckConfig? Health { get; init; } [JsonPropertyName("log-level")] public LogLevelOptions? LoggerLevel { get; init; } diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 558fcc81bb..8ca295082c 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -5,9 +5,18 @@ "connection-string": "Server=tcp:127.0.0.1,1433;Persist Security Info=False;User ID=sa;Password=REPLACEME;MultipleActiveResultSets=False;Connection Timeout=5;", "options": { "set-session-context": true + }, + "health": { + "moniker": "sqlserver", + "enabled": true, + "query": "SELECT TOP 1 10", + "threshold-ms": 1000 } }, "runtime": { + "health": { + "enabled": true + }, "rest": { "enabled": true, "path": "/api", @@ -38,6 +47,11 @@ }, "entities": { "Publisher": { + "health": { + "enabled": true, + "first": 4, + "threshold-ms": 1000 + }, "source": { "object": "publishers", "type": "table" @@ -342,6 +356,11 @@ } }, "Stock": { + "health": { + "enabled": true, + "first": 4, + "threshold-ms": 1000 + }, "source": { "object": "stocks", "type": "table" @@ -524,6 +543,11 @@ } }, "Book": { + "health": { + "enabled": true, + "first": 10, + "threshold-ms": 100 + }, "source": { "object": "books", "type": "table" @@ -1117,6 +1141,11 @@ } }, "Author": { + "health": { + "enabled": true, + "first": 2, + "threshold-ms": 10 + }, "source": { "object": "authors", "type": "table" @@ -3728,4 +3757,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/Service.Tests/dab-healthReport.MsSql.json b/src/Service.Tests/dab-healthReport.MsSql.json new file mode 100644 index 0000000000..54bb5e4c94 --- /dev/null +++ b/src/Service.Tests/dab-healthReport.MsSql.json @@ -0,0 +1,90 @@ +{ + "HealthStatus": "Healthy", + "Version": "1.4.0", + "AppName": "dab_oss_1.4.0", + "DabConfigurationDetails": { + "Rest": true, + "GraphQL": true, + "Caching": false, + "Telemetry": false, + "Mode": "Development" + }, + "Checks": { + "DataSourceHealthCheckResults": { + "sqlserver": { + "HealthStatus": "Healthy", + "ResponseTimeData": { + "ResponseTimeMs": 1, + "MaxAllowedResponseTimeMs": 1000 + }, + "Name": "sqlserver" + } + }, + "EntityHealthCheckResults": { + "Publisher": { + "Rest": { + "HealthStatus": "Healthy", + "ResponseTimeData": { + "ResponseTimeMs": 138, + "MaxAllowedResponseTimeMs": 1000 + } + }, + "GraphQL": { + "HealthStatus": "Healthy", + "ResponseTimeData": { + "ResponseTimeMs": 808, + "MaxAllowedResponseTimeMs": 1000 + } + } + }, + "Stock": { + "Rest": { + "HealthStatus": "Healthy", + "ResponseTimeData": { + "ResponseTimeMs": 9, + "MaxAllowedResponseTimeMs": 1000 + } + }, + "GraphQL": { + "HealthStatus": "Healthy", + "ResponseTimeData": { + "ResponseTimeMs": 41, + "MaxAllowedResponseTimeMs": 1000 + } + } + }, + "Book": { + "Rest": { + "HealthStatus": "Healthy", + "ResponseTimeData": { + "ResponseTimeMs": 11, + "MaxAllowedResponseTimeMs": 1000 + } + }, + "GraphQL": { + "HealthStatus": "Healthy", + "ResponseTimeData": { + "ResponseTimeMs": 39, + "MaxAllowedResponseTimeMs": 1000 + } + } + }, + "Author": { + "Rest": { + "HealthStatus": "Healthy", + "ResponseTimeData": { + "ResponseTimeMs": 8, + "MaxAllowedResponseTimeMs": 1000 + } + }, + "GraphQL": { + "HealthStatus": "Healthy", + "ResponseTimeData": { + "ResponseTimeMs": 38, + "MaxAllowedResponseTimeMs": 1000 + } + } + } + } + } +} \ No newline at end of file diff --git a/src/Service/HealthCheck/EnhancedFormat/EnhancedHealthReportResponseWriter.cs b/src/Service/HealthCheck/EnhancedFormat/EnhancedHealthReportResponseWriter.cs new file mode 100644 index 0000000000..0f7b4df2c5 --- /dev/null +++ b/src/Service/HealthCheck/EnhancedFormat/EnhancedHealthReportResponseWriter.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Config.ObjectModel; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +namespace Azure.DataApiBuilder.Service.HealthCheck +{ + /// + /// Creates a JSON response for the health check endpoint using the provided health report. + /// If the response has already been created, it will be reused. + /// + public class EnhancedHealthReportResponseWriter + { + // Dependencies + private ILogger? _logger; + private HealthCheckUtility _healthCheckUtility; + + public EnhancedHealthReportResponseWriter(ILogger? logger, HealthCheckUtility healthCheckUtility) + { + _logger = logger; + _healthCheckUtility = healthCheckUtility; + } + + /* { + "runtime" : { + "health" : { + "enabled": true, (default: true) + "cache-ttl": 5, (optional default: 5) + "max-dop": 5, (optional default: 1) + "roles": ["anonymous", "authenticated"] (optional default: *) + } + }, + { + "data-source" : { + "health" : { + "moniker": "sqlserver", (optional default: NULL) + "enabled": true, (default: true) + "query": "SELECT TOP 1 1", (option) + "threshold-ms": 100 (optional default: 10000) + } + }, + { + "": { + "health": { + "enabled": true, (default: true) + "filter": "Id eq 1" (optional default: null), + "first": 1 (optional default: 1), + "threshold-ms": 100 (optional default: 10000) + } + } */ + /// + /// Function provided to the health check middleware to write the response. + /// + /// HttpContext for writing the response. + /// Result of health check(s). + /// Result of health check(s). + /// Writes the http response to the http context. + public async Task WriteResponse(HttpContext context, HealthReport healthReport, RuntimeConfig config) + { + context.Response.ContentType = Utilities.JSON_CONTENT_TYPE; + LogTrace("Writing health report response."); + DabHealthCheckReport dabHealthCheckReport = await _healthCheckUtility.GetHealthCheckResponse(healthReport, config).ConfigureAwait(false); + FormatDabHealthCheckReport(ref dabHealthCheckReport); + dabHealthCheckReport.HealthCheckResults = null; + + string response = JsonSerializer.Serialize(dabHealthCheckReport, options: new JsonSerializerOptions { WriteIndented = true, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }); + return context.Response.WriteAsync(response); + } + + private static void FormatDabHealthCheckReport(ref DabHealthCheckReport dabHealthCheckReport) + { + if (dabHealthCheckReport.HealthCheckResults == null) + { return; } + + dabHealthCheckReport.Checks = new(); + if (dabHealthCheckReport.HealthCheckResults.DataSourceHealthCheckResults != null) + { + dabHealthCheckReport.Checks.DataSourceHealthCheckResults = new(); + foreach (HealthCheckDetailsResultEntry dataSourceList in dabHealthCheckReport.HealthCheckResults.DataSourceHealthCheckResults) + { + if (dataSourceList.Name != null) + { + dabHealthCheckReport.Checks.DataSourceHealthCheckResults.Add(dataSourceList.Name, dataSourceList); + } + } + } + + if (dabHealthCheckReport.HealthCheckResults.EntityHealthCheckResults != null) + { + dabHealthCheckReport.Checks.EntityHealthCheckResults = new(); + foreach (HealthCheckEntityResultEntry EntityList in dabHealthCheckReport.HealthCheckResults.EntityHealthCheckResults) + { + if (EntityList.Name != null) + { + dabHealthCheckReport.Checks.EntityHealthCheckResults.Add(EntityList.Name, EntityList.EntityHealthCheckResults); + } + } + } + } + + /// + /// Logs a trace message if a logger is present and the logger is enabled for trace events. + /// + /// Message to emit. + private void LogTrace(string message) + { + if (_logger is not null && _logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace(message); + } + } + } +} diff --git a/src/Service/HealthCheck/EnhancedFormat/HealthCheckUtillity.cs b/src/Service/HealthCheck/EnhancedFormat/HealthCheckUtillity.cs new file mode 100644 index 0000000000..7d9d736aa6 --- /dev/null +++ b/src/Service/HealthCheck/EnhancedFormat/HealthCheckUtillity.cs @@ -0,0 +1,293 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Config.ObjectModel; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +namespace Azure.DataApiBuilder.Service.HealthCheck +{ + /// + /// Creates a JSON response for the health check endpoint using the provided health report. + /// If the response has already been created, it will be reused. + /// + public class HealthCheckUtility + { + // Dependencies + private ILogger? _logger; + private HttpUtilities _httpUtility; + + public HealthCheckUtility(ILogger? logger, HttpUtilities httpUtility) + { + _logger = logger; + _httpUtility = httpUtility; + } + + public async Task GetHealthCheckResponse(HealthReport healthReport, RuntimeConfig runtimeConfig) + { + // Create a JSON response for the health check endpoint using the provided health report. + // If the response has already been created, it will be reused. + if (runtimeConfig?.Runtime != null && runtimeConfig.Runtime?.Health != null && runtimeConfig.Runtime.Health.Enabled) + { + LogTrace("Enhanced Health check is enabled in the runtime configuration."); + DabHealthCheckReport dabHealthCheckReport = new() + { + HealthStatus = Config.ObjectModel.HealthStatus.Healthy + }; + UpdateVersionAndAppName(ref dabHealthCheckReport, healthReport); + UpdateDabConfigurationDetails(ref dabHealthCheckReport, runtimeConfig); + await UpdateHealthCheckDetails(dabHealthCheckReport, runtimeConfig); + return dabHealthCheckReport; + } + + return new DabHealthCheckReport + { + HealthStatus = Config.ObjectModel.HealthStatus.Unhealthy + }; + } + + private static void UpdateDabConfigurationDetails(ref DabHealthCheckReport dabHealthCheckReport, RuntimeConfig runtimeConfig) + { + dabHealthCheckReport.DabConfigurationDetails = new DabConfigurationDetails + { + Rest = runtimeConfig?.Runtime?.Rest != null && runtimeConfig.Runtime.Rest.Enabled, + GraphQL = runtimeConfig?.Runtime?.GraphQL != null && runtimeConfig.Runtime.GraphQL.Enabled, + Caching = runtimeConfig?.Runtime?.IsCachingEnabled ?? false, + Telemetry = runtimeConfig?.Runtime?.Telemetry != null, + Mode = runtimeConfig?.Runtime?.Host?.Mode ?? HostMode.Development, + }; + } + + private async Task UpdateHealthCheckDetails(DabHealthCheckReport dabHealthCheckReport, RuntimeConfig runtimeConfig) + { + if (dabHealthCheckReport != null) + { + dabHealthCheckReport.HealthCheckResults = new DabHealthCheckResults() + { + DataSourceHealthCheckResults = new List(), + EntityHealthCheckResults = new List(), + }; + + if (runtimeConfig != null) + { + UpdateDataSourceHealthCheckResults(ref dabHealthCheckReport, runtimeConfig); + await UpdateEntityHealthCheckResults(dabHealthCheckReport, runtimeConfig); + } + } + } + + private async Task UpdateEntityHealthCheckResults(DabHealthCheckReport dabHealthCheckReport, RuntimeConfig runtimeConfig) + { + if (runtimeConfig?.Entities != null && dabHealthCheckReport?.HealthCheckResults?.EntityHealthCheckResults != null) + { + foreach (KeyValuePair Entity in runtimeConfig.Entities.Entities) + { + DabHealthCheckConfig? healthConfig = Entity.Value?.Health; + if (healthConfig != null && healthConfig.Enabled) + { + await PopulateEntityQuery(dabHealthCheckReport, Entity, runtimeConfig); + } + } + } + } + + private async Task PopulateEntityQuery(DabHealthCheckReport dabHealthCheckReport, KeyValuePair entity, RuntimeConfig runtimeConfig) + { + Dictionary entityHealthCheckResults = new(); + if (runtimeConfig?.Runtime?.Rest?.Enabled ?? false) + { + string restSuffixPath = (entity.Value.Rest?.Path ?? entity.Key).TrimStart('/'); + int responseTime = ExecuteSqlEntityQuery(runtimeConfig.Runtime.Rest.Path, restSuffixPath, entity.Value?.Health?.First); + if (responseTime >= 0 && responseTime <= entity.Value?.Health?.ThresholdMs) + { + entityHealthCheckResults.Add("Rest", new HealthCheckDetailsResultEntry + { + ResponseTimeData = new ResponseTimeData + { + ResponseTimeMs = responseTime, + MaxAllowedResponseTimeMs = entity.Value?.Health?.ThresholdMs + }, + HealthStatus = Config.ObjectModel.HealthStatus.Healthy + }); + } + else + { + entityHealthCheckResults.Add("Rest", new HealthCheckDetailsResultEntry + { + Exception = "The Entity is unavailable or response time exceeded the threshold.", + ResponseTimeData = new ResponseTimeData + { + ResponseTimeMs = responseTime, + MaxAllowedResponseTimeMs = entity.Value?.Health?.ThresholdMs + }, + HealthStatus = Config.ObjectModel.HealthStatus.Unhealthy + }); + } + } + + if (runtimeConfig?.Runtime?.GraphQL?.Enabled ?? false) + { + int responseTime = await ExecuteSqlGraphQLEntityQuery(runtimeConfig.Runtime.GraphQL.Path, entity.Key, entity.Value?.Source.Object, entity.Value?.Health?.First).ConfigureAwait(false); + if (responseTime >= 0 && responseTime <= entity.Value?.Health?.ThresholdMs) + { + entityHealthCheckResults.Add("GraphQL", new HealthCheckDetailsResultEntry + { + ResponseTimeData = new ResponseTimeData + { + ResponseTimeMs = responseTime, + MaxAllowedResponseTimeMs = entity.Value?.Health?.ThresholdMs + }, + HealthStatus = Config.ObjectModel.HealthStatus.Healthy + }); + } + else + { + entityHealthCheckResults.Add("GraphQL", new HealthCheckDetailsResultEntry + { + Exception = "The Entity is unavailable or response time exceeded the threshold.", + ResponseTimeData = new ResponseTimeData + { + ResponseTimeMs = responseTime, + MaxAllowedResponseTimeMs = entity.Value?.Health?.ThresholdMs + }, + HealthStatus = Config.ObjectModel.HealthStatus.Unhealthy + }); + } + } + +#pragma warning disable CS8602 // Dereference of a possibly null reference. + dabHealthCheckReport?.HealthCheckResults?.EntityHealthCheckResults.Add(new HealthCheckEntityResultEntry + { + Name = entity.Key, + EntityHealthCheckResults = entityHealthCheckResults + }); +#pragma warning restore CS8602 // Dereference of a possibly null reference. + } + + private void UpdateDataSourceHealthCheckResults(ref DabHealthCheckReport dabHealthCheckReport, RuntimeConfig runtimeConfig) + { + if (runtimeConfig?.DataSource != null && runtimeConfig.DataSource?.Health != null && runtimeConfig.DataSource.Health.Enabled) + { + string query = runtimeConfig.DataSource?.Health.Query ?? string.Empty; + int responseTime = ExecuteSqlDBQuery(query, runtimeConfig.DataSource?.ConnectionString); + if (dabHealthCheckReport?.HealthCheckResults?.DataSourceHealthCheckResults != null) + { + if (responseTime >= 0 && responseTime <= runtimeConfig?.DataSource?.Health.ThresholdMs) + { + dabHealthCheckReport.HealthCheckResults.DataSourceHealthCheckResults.Add(new HealthCheckDetailsResultEntry + { + Name = runtimeConfig?.DataSource?.Health.Moniker ?? Utilities.SqlServerMoniker, + ResponseTimeData = new ResponseTimeData + { + ResponseTimeMs = responseTime, + MaxAllowedResponseTimeMs = runtimeConfig?.DataSource?.Health.ThresholdMs + }, + HealthStatus = Config.ObjectModel.HealthStatus.Healthy + }); + } + else + { + dabHealthCheckReport.HealthCheckResults.DataSourceHealthCheckResults.Add(new HealthCheckDetailsResultEntry + { + Name = runtimeConfig?.DataSource?.Health.Moniker ?? Utilities.SqlServerMoniker, + Exception = "The response time exceeded the threshold.", + ResponseTimeData = new ResponseTimeData + { + ResponseTimeMs = responseTime, + MaxAllowedResponseTimeMs = runtimeConfig?.DataSource?.Health.ThresholdMs + }, + HealthStatus = Config.ObjectModel.HealthStatus.Unhealthy + }); + } + } + } + + } + + private int ExecuteSqlDBQuery(string query, string? connectionString) + { + if (!string.IsNullOrEmpty(query) && !string.IsNullOrEmpty(connectionString)) + { + Stopwatch stopwatch = new(); + stopwatch.Start(); + bool isSuccess = _httpUtility.ExecuteDbQuery(query, connectionString); + stopwatch.Stop(); + return isSuccess ? (int)stopwatch.ElapsedMilliseconds : -1; + } + + return -1; + } + + private int ExecuteSqlEntityQuery(string UriSuffix, string EntityName, int? First) + { + if (!string.IsNullOrEmpty(EntityName)) + { + Stopwatch stopwatch = new(); + stopwatch.Start(); + bool isSuccess = _httpUtility.ExecuteEntityRestQuery(UriSuffix, EntityName, First ?? 1); + stopwatch.Stop(); + return isSuccess ? (int)stopwatch.ElapsedMilliseconds : -1; + } + + return -1; + } + private async Task ExecuteSqlGraphQLEntityQuery(string UriSuffix, string EntityName, string? TableName, int? First) + { + if (!string.IsNullOrEmpty(EntityName)) + { + Stopwatch stopwatch = new(); + stopwatch.Start(); + bool isSuccess = await _httpUtility.ExecuteEntityGraphQLQueryAsync(UriSuffix, EntityName, TableName ?? EntityName, First ?? 1); + stopwatch.Stop(); + return isSuccess ? (int)stopwatch.ElapsedMilliseconds : -1; + } + + return -1; + } + + private void UpdateVersionAndAppName(ref DabHealthCheckReport response, HealthReport healthReport) + { + // Update the version and app name to the response. + if (healthReport.Entries.TryGetValue(key: typeof(DabHealthCheck).Name, out HealthReportEntry healthReportEntry)) + { + if (healthReportEntry.Data.TryGetValue(DabHealthCheck.DAB_VERSION_KEY, out object? versionValue) && versionValue is string versionNumber) + { + response.Version = versionNumber; + } + else + { + LogTrace("DabHealthCheck did not contain the version number in the HealthReport."); + } + + if (healthReportEntry.Data.TryGetValue(DabHealthCheck.DAB_APPNAME_KEY, out object? appNameValue) && appNameValue is string appName) + { + response.AppName = appName; + } + else + { + LogTrace("DabHealthCheck did not contain the app name in the HealthReport."); + } + } + } + + // + /// Logs a trace message if a logger is present and the logger is enabled for trace events. + /// + /// Message to emit. + private void LogTrace(string message) + { + if (_logger is not null && _logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace(message); + } + else + { + Console.WriteLine(message); + } + } + } +} diff --git a/src/Service/HealthCheck/EnhancedFormat/HttpUtilities.cs b/src/Service/HealthCheck/EnhancedFormat/HttpUtilities.cs new file mode 100644 index 0000000000..69f0039125 --- /dev/null +++ b/src/Service/HealthCheck/EnhancedFormat/HttpUtilities.cs @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Config.ObjectModel.GraphQL; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; + +namespace Azure.DataApiBuilder.Service.HealthCheck +{ + public class HttpUtilities + { + private readonly ILogger? _logger; + + /// + /// HttpUtility constructor. + /// + /// Logger + public HttpUtilities(ILogger? logger) + { + _logger = logger; + } + + public bool ExecuteDbQuery(string query, string connectionString) + { + bool isSuccess = false; + // Execute the query on DB and return the response time. + using (SqlConnection connection = new(connectionString)) + { + try + { + SqlCommand command = new(query, connection); + connection.Open(); + SqlDataReader reader = command.ExecuteReader(); + LogTrace("The query executed successfully."); + isSuccess = true; + reader.Close(); + } + catch (Exception ex) + { + LogTrace($"An exception occurred while executing the query: {ex.Message}"); + isSuccess = false; + } + } + + return isSuccess; + } + + public async Task ExecuteEntityGraphQLQueryAsync(string UriSuffix, string EntityName, string TableName, int First) + { + bool isSuccess = false; + try + { + // Base URL of the API that handles SQL operations + string ApiRoute = Utilities.GetServiceRoute(Utilities.BaseUrl, UriSuffix); + if (ApiRoute == string.Empty) + { + LogTrace("The API route is not available, hence HealthEndpoint is not available."); + return isSuccess; + } + + List columnNames = new(); + // Create an instance of HttpClient + using (HttpClient client = CreateClient(ApiRoute)) + { + // Send a POST request to the API + string jsonPayload = Utilities.CreateHttpGraphQLSchemaQuery(); + HttpContent content = new StringContent(jsonPayload, Encoding.UTF8, Utilities.JSON_CONTENT_TYPE); + + HttpResponseMessage response = client.PostAsync(ApiRoute, content).Result; + if (response.IsSuccessStatusCode) + { + // Read the response content as a string + string responseContent = await response.Content.ReadAsStringAsync(); + if (responseContent != null) + { + GraphQLSchemaMode? graphQLSchema = JsonSerializer.Deserialize(responseContent); + if (graphQLSchema != null && graphQLSchema.Data != null && graphQLSchema.Data.Schema != null && graphQLSchema.Data.Schema.Types != null) + { + foreach (Types type in graphQLSchema.Data.Schema.Types) + { + string kindName = type.Kind; + if (kindName.Equals(Utilities.Kind_Object) + && type.Name.Equals(EntityName, StringComparison.OrdinalIgnoreCase)) + { + foreach (Field field in type.Fields) + { + string fieldName = field.Name; + if (!fieldName.Equals(string.Empty) + && ((field?.Type?.Kind.Equals(Utilities.Kind_Scalar) ?? false) + || ((field?.Type?.Kind.Equals(Utilities.Kind_NonNull) ?? false) && (field?.Type?.OfType?.Kind.Equals(Utilities.Kind_Scalar) ?? false)))) + { + columnNames.Add(fieldName); + } + } + } + } + } + } + } + else + { + LogTrace("Request failed with status: " + response.StatusCode); + } + } + + if (columnNames.Any()) + { + using (HttpClient client = CreateClient(ApiRoute)) + { + string jsonPayload = Utilities.CreateHttpGraphQLQuery(TableName, First, columnNames); + HttpContent content = new StringContent(jsonPayload, Encoding.UTF8, Utilities.JSON_CONTENT_TYPE); + + HttpResponseMessage response = client.PostAsync(ApiRoute, content).Result; + if (response.IsSuccessStatusCode) + { + LogTrace("The HealthEndpoint query executed successfully."); + isSuccess = true; + } + } + } + + return isSuccess; + } + catch (Exception ex) + { + LogTrace($"An exception occurred while executing the query: {ex.Message}"); + return isSuccess; + } + } + + public bool ExecuteEntityRestQuery(string UriSuffix, string EntityName, int First) + { + bool isSuccess = false; + try + { + // Base URL of the API that handles SQL operations + string ApiRoute = Utilities.GetServiceRoute(Utilities.BaseUrl, UriSuffix); + if (ApiRoute == string.Empty) + { + LogTrace("The API route is not available, hence HealthEndpoint is not available."); + return isSuccess; + } + + // Create an instance of HttpClient + using (HttpClient client = CreateClient(ApiRoute)) + { + // Send a GET request to the API + ApiRoute = $"{ApiRoute}{Utilities.CreateHttpRestQuery(EntityName, First)}"; + Console.WriteLine($"------------------------{ApiRoute}"); + HttpResponseMessage response = client.GetAsync(ApiRoute).Result; + + if (response.IsSuccessStatusCode) + { + LogTrace("The HealthEndpoint query executed successfully."); + isSuccess = true; + } + } + + return isSuccess; + } + catch (Exception ex) + { + LogTrace($"An exception occurred while executing the query: {ex.Message}"); + return isSuccess; + } + } + + /// + /// Creates a for processing HTTP requests/responses with the test server. + /// + public HttpClient CreateClient(string ApiRoute) + { + return new HttpClient() + { + // Set the base URL for the client + BaseAddress = new Uri(ApiRoute), + DefaultRequestHeaders = + { + Accept = { new MediaTypeWithQualityHeaderValue(Utilities.JSON_CONTENT_TYPE) } + }, + Timeout = TimeSpan.FromSeconds(200), + }; + } + + // + /// Logs a trace message if a logger is present and the logger is enabled for trace events. + /// + /// Message to emit. + private void LogTrace(string message) + { + if (_logger is not null && _logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace(message); + } + else + { + Console.WriteLine(message); + } + } + } +} diff --git a/src/Service/HealthCheck/EnhancedFormat/Utilities.cs b/src/Service/HealthCheck/EnhancedFormat/Utilities.cs new file mode 100644 index 0000000000..37f527df6a --- /dev/null +++ b/src/Service/HealthCheck/EnhancedFormat/Utilities.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Text.Json; + +namespace Azure.DataApiBuilder.Service.HealthCheck +{ + public static class Utilities + { + public static string BaseUrl = "http://localhost:5000"; + public static string Kind_Object = "OBJECT"; + public static string Kind_Scalar = "SCALAR"; + public static string Kind_NonNull = "NON_NULL"; + public const string JSON_CONTENT_TYPE = "application/json"; + public static string SqlServerMoniker = "sqlserver"; + public static string CreateHttpGraphQLSchemaQuery() + { + var payload = new + { + operationName = "IntrospectionQuery", + query = "query IntrospectionQuery {\n __schema {\n queryType {\n name\n }\n mutationType {\n name\n }\n subscriptionType {\n name\n }\n types {\n ...FullType\n }\n directives {\n name\n description\n isRepeatable\n args {\n ...InputValue\n }\n locations\n }\n }\n}\n\nfragment FullType on __Type {\n kind\n name\n description\n specifiedByURL\n oneOf\n fields(includeDeprecated: true) {\n name\n description\n args {\n ...InputValue\n }\n type {\n ...TypeRef\n }\n isDeprecated\n deprecationReason\n }\n inputFields {\n ...InputValue\n }\n interfaces {\n ...TypeRef\n }\n enumValues(includeDeprecated: true) {\n name\n description\n isDeprecated\n deprecationReason\n }\n possibleTypes {\n ...TypeRef\n }\n}\n\nfragment InputValue on __InputValue {\n name\n description\n type {\n ...TypeRef\n }\n defaultValue\n}\n\nfragment TypeRef on __Type {\n kind\n name\n ofType {\n kind\n name\n ofType {\n kind\n name\n ofType {\n kind\n name\n ofType {\n kind\n name\n ofType {\n kind\n name\n }\n }\n }\n }\n }\n}" + }; + + // Serialize the payload to a JSON string + string jsonPayload = JsonSerializer.Serialize(payload); + return jsonPayload; + } + + public static string CreateHttpGraphQLQuery(string entityName, int First, List columnNames) + { + var payload = new + { + //{"query":"{publishers(first:4) {items {id name} }}"} + query = $"{{{entityName.ToLowerInvariant()}(first: {First}) {{items {{ {string.Join(" ", columnNames)} }}}}}}" + }; + + // Serialize the payload to a JSON string + string jsonPayload = JsonSerializer.Serialize(payload); + return jsonPayload; + } + + public static string CreateHttpRestQuery(string entityName, int First) + { + // Create the payload for the REST HTTP request. + // "/EntityName?$first=4" + return $"/{entityName}?$first={First}"; + } + + public static string GetServiceRoute(string route, string UriSuffix) + { + // The RuntimeConfigProvider enforces the expectation that the configured REST and GraphQL path starts with a + // forward slash '/'. This is to ensure that the path is always relative to the base URL. + if (UriSuffix == string.Empty) + { + return string.Empty; + } + + return $"{route}{UriSuffix.ToLowerInvariant()}"; + } + } +} diff --git a/src/Service/HealthCheck/HealthReportResponseWrite.cs b/src/Service/HealthCheck/HealthReportResponseWrite.cs new file mode 100644 index 0000000000..ad91d88cdb --- /dev/null +++ b/src/Service/HealthCheck/HealthReportResponseWrite.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Configurations; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +namespace Azure.DataApiBuilder.Service.HealthCheck +{ + /// + /// Creates a JSON response for the health check endpoint using the provided health report. + /// If the response has already been created, it will be reused. + /// + public class HealthReportResponseWriter + { + // Dependencies + private ILogger? _logger; + private RuntimeConfigProvider _runtimeConfigProvider; + private OriginalHealthReportResponseWriter _originalHealthReportResponseWriter; + private EnhancedHealthReportResponseWriter _enhancedHealthReportResponseWriter; + + public HealthReportResponseWriter( + ILogger? logger, + RuntimeConfigProvider runtimeConfigProvider, + OriginalHealthReportResponseWriter originalHealthReportResponseWriter, + EnhancedHealthReportResponseWriter enhancedHealthReportResponseWriter) + { + _logger = logger; + _runtimeConfigProvider = runtimeConfigProvider; + this._originalHealthReportResponseWriter = originalHealthReportResponseWriter; + this._enhancedHealthReportResponseWriter = enhancedHealthReportResponseWriter; + } + + /// + /// Function provided to the health check middleware to write the response. + /// + /// HttpContext for writing the response. + /// Result of health check(s). + /// Writes the http response to the http context. + public Task WriteResponse(HttpContext context, HealthReport healthReport) + { + RuntimeConfig config = _runtimeConfigProvider.GetConfig(); + if (config?.Runtime != null && config.Runtime?.Health != null && config.Runtime.Health.Enabled) + { + LogTrace("Enhanced Health check is enabled in the runtime configuration."); + return _enhancedHealthReportResponseWriter.WriteResponse(context, healthReport, config); + } + else + { + LogTrace("Showing Health check in original format for runtime configuration."); + return _originalHealthReportResponseWriter.WriteResponse(context, healthReport); + } + } + + // + /// Logs a trace message if a logger is present and the logger is enabled for trace events. + /// + /// Message to emit. + private void LogTrace(string message) + { + if (_logger is not null && _logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace(message); + } + } + } +} diff --git a/src/Service/HealthCheck/DabHealthCheck.cs b/src/Service/HealthCheck/OriginalFormat/DabHealthCheck.cs similarity index 100% rename from src/Service/HealthCheck/DabHealthCheck.cs rename to src/Service/HealthCheck/OriginalFormat/DabHealthCheck.cs diff --git a/src/Service/HealthCheck/HealthReportResponseWriter.cs b/src/Service/HealthCheck/OriginalFormat/OriginalHealthReportResponseWriter.cs similarity index 96% rename from src/Service/HealthCheck/HealthReportResponseWriter.cs rename to src/Service/HealthCheck/OriginalFormat/OriginalHealthReportResponseWriter.cs index fc5da7783b..dffce4d4d7 100644 --- a/src/Service/HealthCheck/HealthReportResponseWriter.cs +++ b/src/Service/HealthCheck/OriginalFormat/OriginalHealthReportResponseWriter.cs @@ -15,7 +15,7 @@ namespace Azure.DataApiBuilder.Service.HealthCheck /// Creates a JSON response for the health check endpoint using the provided health report. /// If the response has already been created, it will be reused. /// - public class HealthReportResponseWriter + public class OriginalHealthReportResponseWriter { // Dependencies private ILogger? _logger; @@ -26,7 +26,7 @@ public class HealthReportResponseWriter // Constants private const string JSON_CONTENT_TYPE = "application/json; charset=utf-8"; - public HealthReportResponseWriter(ILogger? logger) + public OriginalHealthReportResponseWriter(ILogger? logger) { _logger = logger; } diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 7350c23fe2..6c520f3f89 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -184,7 +184,11 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // ILogger explicit creation required for logger to use --LogLevel startup argument specified. services.AddSingleton>(implementationFactory: (serviceProvider) =>