From 2c7859b54d63c7582c4b770628f5eb5cc782e4bc Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:56:18 -0700 Subject: [PATCH 1/2] Fix LogLevel: CLI phase suppression and case-insensitive "Default" config key (#3203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why make this change? - #3201 Two `LogLevel` bugs: Using the `None` LogLevel still emits some `Information` messages (version, config path, etc.), and using `"Default"` (capital D) as a key in `telemetry.log-level` config crashes startup with `NotSupportedException`. ## What is this change? **All logs follow the LogLevel configuration** - Added more specific configuration for logs in the host level, `Program.cs` which where outputing some logs that where not following the LogLevel configuration from CLI and from configuration file. - Add `DynamicLogLevelProvider` class which allows the loggers in the host level to be updated after the RuntimeConfig is parsed and we know the LogLevel. - Add `StartupLogBuffer` class which saves the logs that are created before we know the LogLevel, and sends them to their respective logger after the RuntimeConfig is parsed. **Case-insensitive `"Default"` key in config** - `IsLoggerFilterValid` used `string.Equals` (ordinal), so `"Default"` failed against the registered `"default"` filter. - `GetConfiguredLogLevel` used `TryGetValue("default")` (case-sensitive), silently ignoring `"Default"` keys. - Both fixed with `StringComparison.OrdinalIgnoreCase` / LINQ `FirstOrDefault`. ```json // Now works — previously threw NotSupportedException "telemetry": { "log-level": { "Default": "warning" } } ``` ``` # Now silent — previously emitted "Information: Microsoft.DataApiBuilder ..." dab start --LogLevel None ``` ## How was this tested? - [ ] Integration Tests - [x] Unit Tests - `ValidStringLogLevelFilters`: added `"Default"` (capital D) data row to cover the case-insensitive validation fix. ## Sample Request(s) ```bash # Suppress all output dab start --LogLevel None # Config key now case-insensitive # dab-config.json: # "telemetry": { "log-level": { "Default": "none" } } dab start ``` --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: RubenCerna2079 <32799214+RubenCerna2079@users.noreply.github.com> Co-authored-by: Ruben Cerna Co-authored-by: Aniruddh Munde Co-authored-by: Anusha Kolan --- src/Cli.Tests/EndToEndTests.cs | 4 - src/Config/FileSystemRuntimeConfigLoader.cs | 73 +++++++++++++------ src/Config/ObjectModel/RuntimeConfig.cs | 4 +- src/Config/StartupLogBuffer.cs | 45 ++++++++++++ .../Configurations/RuntimeConfigValidator.cs | 2 +- .../Configuration/ConfigurationTests.cs | 1 + src/Service/Program.cs | 18 ++++- src/Service/Startup.cs | 23 +++++- .../Telemetry/DynamicLogLevelProvider.cs | 31 ++++++++ 9 files changed, 165 insertions(+), 36 deletions(-) create mode 100644 src/Config/StartupLogBuffer.cs create mode 100644 src/Service/Telemetry/DynamicLogLevelProvider.cs diff --git a/src/Cli.Tests/EndToEndTests.cs b/src/Cli.Tests/EndToEndTests.cs index 99a9b77b6e..28704adc79 100644 --- a/src/Cli.Tests/EndToEndTests.cs +++ b/src/Cli.Tests/EndToEndTests.cs @@ -1087,10 +1087,6 @@ public async Task TestExitOfRuntimeEngineWithInvalidConfig( output = await process.StandardOutput.ReadLineAsync(); Assert.IsNotNull(output); StringAssert.Contains(output, $"Setting default minimum LogLevel:", StringComparison.Ordinal); - - output = await process.StandardOutput.ReadLineAsync(); - Assert.IsNotNull(output); - StringAssert.Contains(output, "Starting the runtime engine...", StringComparison.Ordinal); } else { diff --git a/src/Config/FileSystemRuntimeConfigLoader.cs b/src/Config/FileSystemRuntimeConfigLoader.cs index 614cfbd11c..336eadf44d 100644 --- a/src/Config/FileSystemRuntimeConfigLoader.cs +++ b/src/Config/FileSystemRuntimeConfigLoader.cs @@ -58,6 +58,13 @@ public class FileSystemRuntimeConfigLoader : RuntimeConfigLoader /// private readonly IFileSystem _fileSystem; + /// + /// Logger used to log all the events that occur inside of FileSystemRuntimeConfigLoader + /// + private ILogger? _logger; + + private StartupLogBuffer? _logBuffer; + public const string CONFIGFILE_NAME = "dab-config"; public const string CONFIG_EXTENSION = ".json"; public const string ENVIRONMENT_PREFIX = "DAB_"; @@ -81,13 +88,17 @@ public FileSystemRuntimeConfigLoader( HotReloadEventHandler? handler = null, string baseConfigFilePath = DEFAULT_CONFIG_FILE_NAME, string? connectionString = null, - bool isCliLoader = false) + bool isCliLoader = false, + ILogger? logger = null, + StartupLogBuffer? logBuffer = null) : base(handler, connectionString) { _fileSystem = fileSystem; _baseConfigFilePath = baseConfigFilePath; ConfigFilePath = GetFinalConfigFilePath(); _isCliLoader = isCliLoader; + _logger = logger; + _logBuffer = logBuffer; } /// @@ -195,7 +206,7 @@ public bool TryLoadConfig( { if (_fileSystem.File.Exists(path)) { - Console.WriteLine($"Loading config file from {_fileSystem.Path.GetFullPath(path)}."); + SendLogToBufferOrLogger(LogLevel.Information, $"Loading config file from {_fileSystem.Path.GetFullPath(path)}."); // Use File.ReadAllText because DAB doesn't need write access to the file // and ensures the file handle is released immediately after reading. @@ -214,7 +225,8 @@ public bool TryLoadConfig( } catch (IOException ex) { - Console.WriteLine($"IO Exception, retrying due to {ex.Message}"); + SendLogToBufferOrLogger(LogLevel.Warning, $"IO Exception, retrying due to {ex.Message}"); + if (runCount == FileUtilities.RunLimit) { throw; @@ -237,8 +249,7 @@ public bool TryLoadConfig( { if (TrySetupConfigFileWatcher()) { - Console.WriteLine("Monitoring config: {0} for hot-reloading.", ConfigFilePath); - logger?.LogInformation("Monitoring config: {ConfigFilePath} for hot-reloading.", ConfigFilePath); + SendLogToBufferOrLogger(LogLevel.Information, $"Monitoring config: {ConfigFilePath} for hot-reloading."); } // When isDevMode is not null it means we are in a hot-reload scenario, and need to save the previous @@ -248,14 +259,7 @@ public bool TryLoadConfig( // Log error when the mode is changed during hot-reload. if (isDevMode != this.RuntimeConfig.IsDevelopmentMode()) { - if (logger is null) - { - Console.WriteLine("Hot-reload doesn't support switching mode. Please restart the service to switch the mode."); - } - else - { - logger.LogError("Hot-reload doesn't support switching mode. Please restart the service to switch the mode."); - } + SendLogToBufferOrLogger(LogLevel.Error, "Hot-reload doesn't support switching mode. Please restart the service to switch the mode."); } RuntimeConfig.Runtime.Host.Mode = (bool)isDevMode ? HostMode.Development : HostMode.Production; @@ -280,16 +284,8 @@ public bool TryLoadConfig( return false; } - if (logger is null) - { - string errorMessage = $"Unable to find config file: {path} does not exist."; - Console.Error.WriteLine(errorMessage); - } - else - { - string errorMessage = "Unable to find config file: {path} does not exist."; - logger.LogError(message: errorMessage, path); - } + string errorMessage = $"Unable to find config file: {path} does not exist."; + SendLogToBufferOrLogger(LogLevel.Error, errorMessage); config = null; return false; @@ -515,4 +511,35 @@ public void UpdateConfigFilePath(string filePath) _baseConfigFilePath = filePath; ConfigFilePath = filePath; } + + public void SetLogger(ILogger logger) + { + _logger = logger; + } + + /// + /// Flush all logs from the buffer after the log level is set from the RuntimeConfig. + /// + public void FlushLogBuffer() + { + _logBuffer?.FlushToLogger(_logger); + } + + /// + /// Helper method that sends the log to the buffer if the logger has not being set up. + /// Else, it will send the log to the logger. + /// + /// LogLevel of the log. + /// Message that will be printed in the log. + private void SendLogToBufferOrLogger(LogLevel logLevel, string message) + { + if (_logger is null) + { + _logBuffer?.BufferLog(logLevel, message); + } + else + { + _logger?.Log(logLevel, message); + } + } } diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index f1ab0d1f10..ab6764f77a 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -714,7 +714,6 @@ Runtime.Telemetry.LoggerLevel is null || /// public LogLevel GetConfiguredLogLevel(string loggerFilter = "") { - if (!IsLogLevelNull()) { int max = 0; @@ -735,7 +734,8 @@ public LogLevel GetConfiguredLogLevel(string loggerFilter = "") return (LogLevel)value; } - Runtime!.Telemetry!.LoggerLevel!.TryGetValue("default", out value); + value = Runtime!.Telemetry!.LoggerLevel! + .SingleOrDefault(kvp => kvp.Key.Equals("default", StringComparison.OrdinalIgnoreCase)).Value; if (value is not null) { return (LogLevel)value; diff --git a/src/Config/StartupLogBuffer.cs b/src/Config/StartupLogBuffer.cs new file mode 100644 index 0000000000..4a01ee7617 --- /dev/null +++ b/src/Config/StartupLogBuffer.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +namespace Azure.DataApiBuilder.Config +{ + /// + /// A general-purpose log buffer that stores log entries before the final log level is determined. + /// Can be used across different components during startup to capture important early logs. + /// + public class StartupLogBuffer + { + private readonly ConcurrentQueue<(LogLevel LogLevel, string Message)> _logBuffer; + private readonly object _flushLock = new(); + + public StartupLogBuffer() + { + _logBuffer = new(); + } + + /// + /// Buffers a log entry with a specific category name. + /// + public void BufferLog(LogLevel logLevel, string message) + { + _logBuffer.Enqueue((logLevel, message)); + } + + /// + /// Flushes all buffered logs to a single target logger. + /// + public void FlushToLogger(ILogger? targetLogger) + { + lock (_flushLock) + { + while (_logBuffer.TryDequeue(out (LogLevel LogLevel, string Message) entry)) + { + targetLogger?.Log(entry.LogLevel, message: entry.Message); + } + } + } + } +} diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index ec97a48e4c..80572a2298 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -1502,7 +1502,7 @@ private static bool IsLoggerFilterValid(string loggerFilter) { for (int j = 0; j < loggerSub.Length; j++) { - if (!loggerSub[j].Equals(validFiltersSub[j])) + if (!loggerSub[j].Equals(validFiltersSub[j], StringComparison.OrdinalIgnoreCase)) { isValid = false; break; diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index d42af33259..391b6ed1c6 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -4057,6 +4057,7 @@ public void ValidLogLevelFilters(LogLevel logLevel, Type loggingType) [DataTestMethod] [TestCategory(TestCategory.MSSQL)] [DataRow(LogLevel.Trace, "default")] + [DataRow(LogLevel.Warning, "Default")] [DataRow(LogLevel.Debug, "Azure")] [DataRow(LogLevel.Information, "Azure.DataApiBuilder")] [DataRow(LogLevel.Warning, "Azure.DataApiBuilder.Core")] diff --git a/src/Service/Program.cs b/src/Service/Program.cs index e23fb98cd9..368eaffc46 100644 --- a/src/Service/Program.cs +++ b/src/Service/Program.cs @@ -33,6 +33,7 @@ namespace Azure.DataApiBuilder.Service public class Program { public static bool IsHttpsRedirectionDisabled { get; private set; } + public static DynamicLogLevelProvider LogLevelProvider = new(); public static void Main(string[] args) { @@ -59,7 +60,6 @@ public static void Main(string[] args) public static bool StartEngine(string[] args, bool runMcpStdio, string? mcpRole) { - Console.WriteLine("Starting the runtime engine..."); try { IHost host = CreateHostBuilder(args, runMcpStdio, mcpRole).Build(); @@ -107,9 +107,19 @@ public static IHostBuilder CreateHostBuilder(string[] args, bool runMcpStdio, st McpStdioHelper.ConfigureMcpStdio(builder, mcpRole); } }) + .ConfigureServices((context, services) => + { + services.AddSingleton(LogLevelProvider); + }) + .ConfigureLogging(logging => + { + logging.AddFilter("Microsoft", logLevel => LogLevelProvider.ShouldLog(logLevel)); + logging.AddFilter("Microsoft.Hosting.Lifetime", logLevel => LogLevelProvider.ShouldLog(logLevel)); + }) .ConfigureWebHostDefaults(webBuilder => { Startup.MinimumLogLevel = GetLogLevelFromCommandLineArgs(args, out Startup.IsLogLevelOverriddenByCli); + LogLevelProvider.SetInitialLogLevel(Startup.MinimumLogLevel, Startup.IsLogLevelOverriddenByCli); ILoggerFactory loggerFactory = GetLoggerFactoryForLogLevel(Startup.MinimumLogLevel, stdio: runMcpStdio); ILogger startupLogger = loggerFactory.CreateLogger(); DisableHttpsRedirectionIfNeeded(args); @@ -185,9 +195,9 @@ public static ILoggerFactory GetLoggerFactoryForLogLevel( // "Azure.DataApiBuilder.Service" if (logLevelInitializer is null) { - builder.AddFilter(category: "Microsoft", logLevel); - builder.AddFilter(category: "Azure", logLevel); - builder.AddFilter(category: "Default", logLevel); + builder.AddFilter(category: "Microsoft", logLevel => LogLevelProvider.ShouldLog(logLevel)); + builder.AddFilter(category: "Azure", logLevel => LogLevelProvider.ShouldLog(logLevel)); + builder.AddFilter(category: "Default", logLevel => LogLevelProvider.ShouldLog(logLevel)); } else { diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index ca69e59a16..9cd553446f 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -81,6 +81,7 @@ public class Startup(IConfiguration configuration, ILogger logger) public static AzureLogAnalyticsOptions AzureLogAnalyticsOptions = new(); public static FileSinkOptions FileSinkOptions = new(); public const string NO_HTTPS_REDIRECT_FLAG = "--no-https-redirect"; + private StartupLogBuffer _logBuffer = new(); private readonly HotReloadEventHandler _hotReloadEventHandler = new(); private RuntimeConfigProvider? _configProvider; private ILogger _logger = logger; @@ -100,13 +101,15 @@ public class Startup(IConfiguration configuration, ILogger logger) public void ConfigureServices(IServiceCollection services) { Startup.AddValidFilters(); + services.AddSingleton(_logBuffer); + services.AddSingleton(Program.LogLevelProvider); services.AddSingleton(_hotReloadEventHandler); string configFileName = Configuration.GetValue("ConfigFileName") ?? FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME; string? connectionString = Configuration.GetValue( FileSystemRuntimeConfigLoader.RUNTIME_ENV_CONNECTION_STRING.Replace(FileSystemRuntimeConfigLoader.ENVIRONMENT_PREFIX, ""), null); IFileSystem fileSystem = new FileSystem(); - FileSystemRuntimeConfigLoader configLoader = new(fileSystem, _hotReloadEventHandler, configFileName, connectionString); + FileSystemRuntimeConfigLoader configLoader = new(fileSystem, _hotReloadEventHandler, configFileName, connectionString, logBuffer: _logBuffer); RuntimeConfigProvider configProvider = new(configLoader); _configProvider = configProvider; @@ -225,6 +228,13 @@ public void ConfigureServices(IServiceCollection services) services.AddHealthChecks() .AddCheck(nameof(BasicHealthCheck)); + services.AddSingleton>(implementationFactory: (serviceProvider) => + { + LogLevelInitializer logLevelInit = new(MinimumLogLevel, typeof(FileSystemRuntimeConfigLoader).FullName, _configProvider, _hotReloadEventHandler); + ILoggerFactory? loggerFactory = CreateLoggerFactoryForHostedAndNonHostedScenario(serviceProvider, logLevelInit); + return loggerFactory.CreateLogger(); + }); + services.AddSingleton>(implementationFactory: (serviceProvider) => { LogLevelInitializer logLevelInit = new(MinimumLogLevel, typeof(SqlQueryEngine).FullName, _configProvider, _hotReloadEventHandler); @@ -569,7 +579,16 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC if (runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) { - // Configure Application Insights Telemetry + // Set LogLevel based on RuntimeConfig + DynamicLogLevelProvider logLevelProvider = app.ApplicationServices.GetRequiredService(); + logLevelProvider.UpdateFromRuntimeConfig(runtimeConfig); + FileSystemRuntimeConfigLoader configLoader = app.ApplicationServices.GetRequiredService(); + + //Flush all logs that were buffered before setting the LogLevel + configLoader.SetLogger(app.ApplicationServices.GetRequiredService>()); + configLoader.FlushLogBuffer(); + + // Configure Telemetry ConfigureApplicationInsightsTelemetry(app, runtimeConfig); ConfigureOpenTelemetry(runtimeConfig); ConfigureAzureLogAnalytics(runtimeConfig); diff --git a/src/Service/Telemetry/DynamicLogLevelProvider.cs b/src/Service/Telemetry/DynamicLogLevelProvider.cs new file mode 100644 index 0000000000..3c35e295e6 --- /dev/null +++ b/src/Service/Telemetry/DynamicLogLevelProvider.cs @@ -0,0 +1,31 @@ +using Azure.DataApiBuilder.Config.ObjectModel; +using Microsoft.Extensions.Logging; + +namespace Azure.DataApiBuilder.Service.Telemetry +{ + public class DynamicLogLevelProvider + { + public LogLevel CurrentLogLevel { get; private set; } + public bool IsCliOverridden { get; private set; } + + public void SetInitialLogLevel(LogLevel logLevel = LogLevel.Error, bool isCliOverridden = false) + { + CurrentLogLevel = logLevel; + IsCliOverridden = isCliOverridden; + } + + public void UpdateFromRuntimeConfig(RuntimeConfig runtimeConfig) + { + // Only update if CLI didn't override + if (!IsCliOverridden) + { + CurrentLogLevel = runtimeConfig.GetConfiguredLogLevel(); + } + } + + public bool ShouldLog(LogLevel logLevel) + { + return logLevel >= CurrentLogLevel; + } + } +} From 717c1437a1e2f966179fefa94ac8147a51475d50 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:07:57 +0530 Subject: [PATCH 2/2] Fix: Exclude stored procedures from health checks (#2997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why make this change? Closes #2977 Health check endpoint was returning results for stored procedures. Stored procedures should be excluded because: 1. They require parameters not configurable via health settings 2. They are not deterministic, making health checks unreliable ## What is this change? Added filter in `HealthCheckHelper.UpdateEntityHealthCheckResultsAsync()` to exclude entities with `EntitySourceType.StoredProcedure`: ```csharp // Before .Where(e => e.Value.IsEntityHealthEnabled) // After .Where(e => e.Value.IsEntityHealthEnabled && e.Value.Source.Type != EntitySourceType.StoredProcedure) ``` Only tables and views are now included in entity health checks. ## How was this tested? - [ ] Integration Tests - [x] Unit Tests Added `HealthChecks_ExcludeStoredProcedures()` unit test that creates a `RuntimeConfig` with both table and stored procedure entities, then applies the same filter used in `HealthCheckHelper.UpdateEntityHealthCheckResultsAsync` to verify stored procedures are excluded while tables are included. ## Sample Request(s) Health check response after fix (stored procedure `GetSeriesActors` no longer appears): ```json { "status": "Healthy", "checks": [ { "name": "MSSQL", "tags": ["data-source"] }, { "name": "Book", "tags": ["rest", "endpoint"] } ] } ```
Original prompt > > ---- > > *This section details on the original issue you should resolve* > > [Bug]: Health erroneously checks Stored Procedures > ## What? > > Health check returns check results for stored procs. It should ONLY include tables and views. > > ## Health output sample > > ```json > { > "status": "Healthy", > "version": "1.7.81", > "app-name": "dab_oss_1.7.81", > "timestamp": "2025-11-17T20:33:42.2752261Z", > "configuration": { > "rest": true, > "graphql": true, > "mcp": true, > "caching": true, > "telemetry": false, > "mode": "Development" > }, > "checks": [ > { > "status": "Healthy", > "name": "MSSQL", > "tags": [ > "data-source" > ], > "data": { > "response-ms": 3, > "threshold-ms": 1000 > } > }, > { > "status": "Healthy", > "name": "GetSeriesActors", // stored procedure > "tags": [ > "graphql", > "endpoint" > ], > "data": { > "response-ms": 1, > "threshold-ms": 1000 > } > }, > { > "status": "Healthy", > "name": "GetSeriesActors", // stored procedure > "tags": [ > "rest", > "endpoint" > ], > "data": { > "response-ms": 5, > "threshold-ms": 1000 > } > } > ] > } > ``` > > ## Comments on the Issue (you are @copilot in this section) > > > @souvikghosh04 > @JerryNixon / @Aniruddh25 should stored procedures and functions be discarded from health checks permanently? > @JerryNixon > The entity checks in the Health endpoint check every table and view type entity with a user-configurable select with a first compared against a user-configurable threshold. We do not check stored procedures, and cannot check stored procedures, as we do not have any mechanism to take parameters as Health configuration values. Also stored procedures are not guaranteed to be deterministic, making checks that would call them potentially be unreliable. So, yes, stored procedures should be ignored. > >
- Fixes Azure/data-api-builder#2982 --- 💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Aniruddh25 <3513779+Aniruddh25@users.noreply.github.com> Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com> --- .../Configuration/HealthEndpointTests.cs | 88 +++++++++++++++++++ src/Service/HealthCheck/HealthCheckHelper.cs | 3 +- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/Service.Tests/Configuration/HealthEndpointTests.cs b/src/Service.Tests/Configuration/HealthEndpointTests.cs index 7f84d4fa15..a1a8bf4223 100644 --- a/src/Service.Tests/Configuration/HealthEndpointTests.cs +++ b/src/Service.Tests/Configuration/HealthEndpointTests.cs @@ -564,6 +564,94 @@ private static RuntimeConfig CreateRuntimeConfig(Dictionary enti return runtimeConfig; } + /// + /// Verifies that stored procedures are excluded from health check results. + /// Creates a config with both a table entity and a stored procedure entity, + /// then validates that only the table entity appears in the health endpoint response. + /// + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task HealthEndpoint_ExcludesStoredProcedures() + { + // Create a table entity + Entity tableEntity = new( + Health: new(enabled: true), + Source: new("books", EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Enabled: true), + GraphQL: new("book", "bookLists", true), + Permissions: new[] { ConfigurationTests.GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); + + // Create a stored procedure entity - using an actual stored procedure from test schema + Entity storedProcEntity = new( + Health: new(enabled: true), + Source: new("get_books", EntitySourceType.StoredProcedure, null, null), + Fields: null, + Rest: new(Enabled: true), + GraphQL: new("executeGetBooks", "executeGetBooksList", true), + Permissions: new[] { ConfigurationTests.GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); + + Dictionary entityMap = new() + { + { "Book", tableEntity }, + { "GetBooks", storedProcEntity } + }; + + RuntimeConfig runtimeConfig = CreateRuntimeConfig( + entityMap, + enableGlobalRest: true, + enableGlobalGraphql: true, + enabledGlobalMcp: true, + enableGlobalHealth: true, + enableDatasourceHealth: true, + hostMode: HostMode.Development); + + WriteToCustomConfigFile(runtimeConfig); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + HttpRequestMessage healthRequest = new(HttpMethod.Get, $"{BASE_DAB_URL}/health"); + HttpResponseMessage response = await client.SendAsync(healthRequest); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Health endpoint should return OK"); + + string responseBody = await response.Content.ReadAsStringAsync(); + Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); + + // Get the checks array + Assert.IsTrue(responseProperties.TryGetValue("checks", out JsonElement checksElement), "Response should contain 'checks' property"); + Assert.AreEqual(JsonValueKind.Array, checksElement.ValueKind, "Checks should be an array"); + + // Get all entity names from the health check results + List entityNamesInHealthCheck = new(); + foreach (JsonElement check in checksElement.EnumerateArray()) + { + if (check.TryGetProperty("name", out JsonElement nameElement)) + { + entityNamesInHealthCheck.Add(nameElement.GetString()); + } + } + + // Verify that the table entity (Book) appears in health checks + Assert.IsTrue(entityNamesInHealthCheck.Contains("Book"), + "Table entity 'Book' should be included in health check results"); + + // Verify that the stored procedure entity (GetBooks) does NOT appear in health checks + Assert.IsFalse(entityNamesInHealthCheck.Contains("GetBooks"), + "Stored procedure entity 'GetBooks' should be excluded from health check results"); + } + } + private static void WriteToCustomConfigFile(RuntimeConfig runtimeConfig) { File.WriteAllText( diff --git a/src/Service/HealthCheck/HealthCheckHelper.cs b/src/Service/HealthCheck/HealthCheckHelper.cs index 83b8b7a301..72d93521ea 100644 --- a/src/Service/HealthCheck/HealthCheckHelper.cs +++ b/src/Service/HealthCheck/HealthCheckHelper.cs @@ -198,10 +198,11 @@ private async Task UpdateDataSourceHealthCheckResultsAsync(ComprehensiveHealthCh // 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). + // Stored procedures are excluded from health checks because they require parameters and are not guaranteed to be deterministic. private async Task UpdateEntityHealthCheckResultsAsync(ComprehensiveHealthCheckReport report, RuntimeConfig runtimeConfig) { List> enabledEntities = runtimeConfig.Entities.Entities - .Where(e => e.Value.IsEntityHealthEnabled) + .Where(e => e.Value.IsEntityHealthEnabled && e.Value.Source.Type != EntitySourceType.StoredProcedure) .ToList(); if (enabledEntities.Count == 0)