Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions src/Cli.Tests/EndToEndTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
73 changes: 50 additions & 23 deletions src/Config/FileSystemRuntimeConfigLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ public class FileSystemRuntimeConfigLoader : RuntimeConfigLoader
/// </summary>
private readonly IFileSystem _fileSystem;

/// <summary>
/// Logger used to log all the events that occur inside of FileSystemRuntimeConfigLoader
/// </summary>
private ILogger<FileSystemRuntimeConfigLoader>? _logger;

private StartupLogBuffer? _logBuffer;

public const string CONFIGFILE_NAME = "dab-config";
public const string CONFIG_EXTENSION = ".json";
public const string ENVIRONMENT_PREFIX = "DAB_";
Expand All @@ -81,13 +88,17 @@ public FileSystemRuntimeConfigLoader(
HotReloadEventHandler<HotReloadEventArgs>? handler = null,
string baseConfigFilePath = DEFAULT_CONFIG_FILE_NAME,
string? connectionString = null,
bool isCliLoader = false)
bool isCliLoader = false,
ILogger<FileSystemRuntimeConfigLoader>? logger = null,
StartupLogBuffer? logBuffer = null)
: base(handler, connectionString)
{
_fileSystem = fileSystem;
_baseConfigFilePath = baseConfigFilePath;
ConfigFilePath = GetFinalConfigFilePath();
_isCliLoader = isCliLoader;
_logger = logger;
_logBuffer = logBuffer;
}

/// <summary>
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -515,4 +511,35 @@ public void UpdateConfigFilePath(string filePath)
_baseConfigFilePath = filePath;
ConfigFilePath = filePath;
}

public void SetLogger(ILogger<FileSystemRuntimeConfigLoader> logger)
{
_logger = logger;
}

/// <summary>
/// Flush all logs from the buffer after the log level is set from the RuntimeConfig.
/// </summary>
public void FlushLogBuffer()
{
_logBuffer?.FlushToLogger(_logger);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="logLevel">LogLevel of the log.</param>
/// <param name="message">Message that will be printed in the log.</param>
private void SendLogToBufferOrLogger(LogLevel logLevel, string message)
{
if (_logger is null)
{
_logBuffer?.BufferLog(logLevel, message);
}
else
{
_logger?.Log(logLevel, message);
}
}
}
4 changes: 2 additions & 2 deletions src/Config/ObjectModel/RuntimeConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,6 @@ Runtime.Telemetry.LoggerLevel is null ||
/// </summary>
public LogLevel GetConfiguredLogLevel(string loggerFilter = "")
{

if (!IsLogLevelNull())
{
int max = 0;
Expand All @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions src/Config/StartupLogBuffer.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public class StartupLogBuffer
{
private readonly ConcurrentQueue<(LogLevel LogLevel, string Message)> _logBuffer;
private readonly object _flushLock = new();

public StartupLogBuffer()
{
_logBuffer = new();
}

/// <summary>
/// Buffers a log entry with a specific category name.
/// </summary>
public void BufferLog(LogLevel logLevel, string message)
{
_logBuffer.Enqueue((logLevel, message));
}

/// <summary>
/// Flushes all buffered logs to a single target logger.
/// </summary>
public void FlushToLogger(ILogger? targetLogger)
{
lock (_flushLock)
{
while (_logBuffer.TryDequeue(out (LogLevel LogLevel, string Message) entry))
{
targetLogger?.Log(entry.LogLevel, message: entry.Message);
}
}
}
}
}
2 changes: 1 addition & 1 deletion src/Core/Configurations/RuntimeConfigValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/Service.Tests/Configuration/ConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
88 changes: 88 additions & 0 deletions src/Service.Tests/Configuration/HealthEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,94 @@ private static RuntimeConfig CreateRuntimeConfig(Dictionary<string, Entity> enti
return runtimeConfig;
}

/// <summary>
/// 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.
/// </summary>
[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<string, Entity> 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<string, JsonElement> responseProperties = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(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<string> 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(
Expand Down
3 changes: 2 additions & 1 deletion src/Service/HealthCheck/HealthCheckHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<KeyValuePair<string, Entity>> 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)
Expand Down
18 changes: 14 additions & 4 deletions src/Service/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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();
Expand Down Expand Up @@ -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<Startup> startupLogger = loggerFactory.CreateLogger<Startup>();
DisableHttpsRedirectionIfNeeded(args);
Expand Down Expand Up @@ -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
{
Expand Down
Loading
Loading