Skip to content

Commit 96968ab

Browse files
Support health check (#644)
* support health check * update * reord last successful time in ExecuteWithFailoverPolicy * make health check compatible with DI * add health check for each provider instance * update * update comment
1 parent cba86dd commit 96968ab

File tree

6 files changed

+301
-1
lines changed

6 files changed

+301
-1
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
//
4+
using Microsoft.Extensions.Diagnostics.HealthChecks;
5+
using System;
6+
using System.Collections.Generic;
7+
using System.Reflection;
8+
using System.Threading.Tasks;
9+
using System.Threading;
10+
using System.Linq;
11+
12+
namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
13+
{
14+
internal class AzureAppConfigurationHealthCheck : IHealthCheck
15+
{
16+
private static readonly PropertyInfo _propertyInfo = typeof(ChainedConfigurationProvider).GetProperty("Configuration", BindingFlags.Public | BindingFlags.Instance);
17+
private readonly IEnumerable<IHealthCheck> _healthChecks;
18+
19+
public AzureAppConfigurationHealthCheck(IConfiguration configuration)
20+
{
21+
if (configuration == null)
22+
{
23+
throw new ArgumentNullException(nameof(configuration));
24+
}
25+
26+
var healthChecks = new List<IHealthCheck>();
27+
var configurationRoot = configuration as IConfigurationRoot;
28+
FindHealthChecks(configurationRoot, healthChecks);
29+
30+
_healthChecks = healthChecks;
31+
}
32+
33+
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
34+
{
35+
if (!_healthChecks.Any())
36+
{
37+
return HealthCheckResult.Unhealthy(HealthCheckConstants.NoProviderFoundMessage);
38+
}
39+
40+
foreach (IHealthCheck healthCheck in _healthChecks)
41+
{
42+
var result = await healthCheck.CheckHealthAsync(context, cancellationToken).ConfigureAwait(false);
43+
44+
if (result.Status == HealthStatus.Unhealthy)
45+
{
46+
return result;
47+
}
48+
}
49+
50+
return HealthCheckResult.Healthy();
51+
}
52+
53+
private void FindHealthChecks(IConfigurationRoot configurationRoot, List<IHealthCheck> healthChecks)
54+
{
55+
if (configurationRoot != null)
56+
{
57+
foreach (IConfigurationProvider provider in configurationRoot.Providers)
58+
{
59+
if (provider is AzureAppConfigurationProvider appConfigurationProvider)
60+
{
61+
healthChecks.Add(appConfigurationProvider);
62+
}
63+
else if (provider is ChainedConfigurationProvider chainedProvider)
64+
{
65+
if (_propertyInfo != null)
66+
{
67+
var chainedProviderConfigurationRoot = _propertyInfo.GetValue(chainedProvider) as IConfigurationRoot;
68+
FindHealthChecks(chainedProviderConfigurationRoot, healthChecks);
69+
}
70+
}
71+
}
72+
}
73+
}
74+
}
75+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
//
4+
using Microsoft.Extensions.Configuration;
5+
using Microsoft.Extensions.Configuration.AzureAppConfiguration;
6+
using Microsoft.Extensions.Diagnostics.HealthChecks;
7+
using System;
8+
using System.Collections.Generic;
9+
10+
namespace Microsoft.Extensions.DependencyInjection
11+
{
12+
/// <summary>
13+
/// Extension methods to configure <see cref="AzureAppConfigurationHealthCheck"/>.
14+
/// </summary>
15+
public static class AzureAppConfigurationHealthChecksBuilderExtensions
16+
{
17+
/// <summary>
18+
/// Add a health check for Azure App Configuration to given <paramref name="builder"/>.
19+
/// </summary>
20+
/// <param name="builder">The <see cref="IHealthChecksBuilder"/> to add <see cref="HealthCheckRegistration"/> to.</param>
21+
/// <param name="factory"> A factory to obtain <see cref="IConfiguration"/> instance.</param>
22+
/// <param name="name">The health check name.</param>
23+
/// <param name="failureStatus">The <see cref="HealthStatus"/> that should be reported when the health check fails.</param>
24+
/// <param name="tags">A list of tags that can be used to filter sets of health checks.</param>
25+
/// <param name="timeout">A <see cref="TimeSpan"/> representing the timeout of the check.</param>
26+
/// <returns>The provided health checks builder.</returns>
27+
public static IHealthChecksBuilder AddAzureAppConfiguration(
28+
this IHealthChecksBuilder builder,
29+
Func<IServiceProvider, IConfiguration> factory = default,
30+
string name = HealthCheckConstants.HealthCheckRegistrationName,
31+
HealthStatus failureStatus = default,
32+
IEnumerable<string> tags = default,
33+
TimeSpan? timeout = default)
34+
{
35+
return builder.Add(new HealthCheckRegistration(
36+
name ?? HealthCheckConstants.HealthCheckRegistrationName,
37+
sp => new AzureAppConfigurationHealthCheck(
38+
factory?.Invoke(sp) ?? sp.GetRequiredService<IConfiguration>()),
39+
failureStatus,
40+
tags,
41+
timeout));
42+
}
43+
}
44+
}
45+

src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Azure.Data.AppConfiguration;
66
using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions;
77
using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models;
8+
using Microsoft.Extensions.Diagnostics.HealthChecks;
89
using Microsoft.Extensions.Logging;
910
using System;
1011
using System.Collections.Generic;
@@ -21,7 +22,7 @@
2122

2223
namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
2324
{
24-
internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigurationRefresher, IDisposable
25+
internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigurationRefresher, IHealthCheck, IDisposable
2526
{
2627
private readonly ActivitySource _activitySource = new ActivitySource(ActivityNames.AzureAppConfigurationActivitySource);
2728
private bool _optional;
@@ -53,6 +54,10 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura
5354
private Logger _logger = new Logger();
5455
private ILoggerFactory _loggerFactory;
5556

57+
// For health check
58+
private DateTimeOffset? _lastSuccessfulAttempt = null;
59+
private DateTimeOffset? _lastFailedAttempt = null;
60+
5661
private class ConfigurationClientBackoffStatus
5762
{
5863
public int FailedAttempts { get; set; }
@@ -256,6 +261,8 @@ public async Task RefreshAsync(CancellationToken cancellationToken)
256261

257262
_logger.LogDebug(LogHelper.BuildRefreshSkippedNoClientAvailableMessage());
258263

264+
_lastFailedAttempt = DateTime.UtcNow;
265+
259266
return;
260267
}
261268

@@ -571,6 +578,22 @@ public void ProcessPushNotification(PushNotification pushNotification, TimeSpan?
571578
}
572579
}
573580

581+
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
582+
{
583+
if (!_lastSuccessfulAttempt.HasValue)
584+
{
585+
return HealthCheckResult.Unhealthy(HealthCheckConstants.LoadNotCompletedMessage);
586+
}
587+
588+
if (_lastFailedAttempt.HasValue &&
589+
_lastSuccessfulAttempt.Value < _lastFailedAttempt.Value)
590+
{
591+
return HealthCheckResult.Unhealthy(HealthCheckConstants.RefreshFailedMessage);
592+
}
593+
594+
return HealthCheckResult.Healthy();
595+
}
596+
574597
private void SetDirty(TimeSpan? maxDelay)
575598
{
576599
DateTimeOffset nextRefreshTime = AddRandomDelay(DateTimeOffset.UtcNow, maxDelay ?? DefaultMaxSetDirtyDelay);
@@ -1158,6 +1181,7 @@ private async Task<T> ExecuteWithFailOverPolicyAsync<T>(
11581181
success = true;
11591182

11601183
_lastSuccessfulEndpoint = _configClientManager.GetEndpointForClient(currentClient);
1184+
_lastSuccessfulAttempt = DateTime.UtcNow;
11611185

11621186
return result;
11631187
}
@@ -1183,6 +1207,7 @@ private async Task<T> ExecuteWithFailOverPolicyAsync<T>(
11831207
{
11841208
if (!success && backoffAllClients)
11851209
{
1210+
_lastFailedAttempt = DateTime.UtcNow;
11861211
_logger.LogWarning(LogHelper.BuildLastEndpointFailedMessage(previousEndpoint?.ToString()));
11871212

11881213
do
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
//
4+
5+
namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
6+
{
7+
internal class HealthCheckConstants
8+
{
9+
public const string HealthCheckRegistrationName = "Microsoft.Extensions.Configuration.AzureAppConfiguration";
10+
public const string NoProviderFoundMessage = "No configuration provider is found.";
11+
public const string LoadNotCompletedMessage = "The initial load is not completed.";
12+
public const string RefreshFailedMessage = "The last refresh attempt failed.";
13+
}
14+
}

src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.7.0" />
2020
<PackageReference Include="Azure.Security.KeyVault.Secrets" Version="4.6.0" />
2121
<PackageReference Include="DnsClient" Version="1.7.0" />
22+
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="6.0.36" />
2223
<PackageReference Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
2324
<PackageReference Include="Microsoft.Extensions.Azure" Version="1.7.6" />
2425
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
//
4+
using Azure;
5+
using Azure.Data.AppConfiguration;
6+
using Microsoft.Extensions.Configuration;
7+
using Microsoft.Extensions.Configuration.AzureAppConfiguration;
8+
using Microsoft.Extensions.Diagnostics.HealthChecks;
9+
using Moq;
10+
using System.Threading;
11+
using System.Collections.Generic;
12+
using System.Threading.Tasks;
13+
using Xunit;
14+
using System;
15+
using System.Linq;
16+
using Microsoft.Extensions.DependencyInjection;
17+
18+
namespace Tests.AzureAppConfiguration
19+
{
20+
public class HealthCheckTest
21+
{
22+
readonly List<ConfigurationSetting> kvCollection = new List<ConfigurationSetting>
23+
{
24+
ConfigurationModelFactory.ConfigurationSetting("TestKey1", "TestValue1", "label",
25+
eTag: new ETag("0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63"),
26+
contentType:"text"),
27+
ConfigurationModelFactory.ConfigurationSetting("TestKey2", "TestValue2", "label",
28+
eTag: new ETag("31c38369-831f-4bf1-b9ad-79db56c8b989"),
29+
contentType: "text"),
30+
ConfigurationModelFactory.ConfigurationSetting("TestKey3", "TestValue3", "label",
31+
32+
eTag: new ETag("bb203f2b-c113-44fc-995d-b933c2143339"),
33+
contentType: "text"),
34+
ConfigurationModelFactory.ConfigurationSetting("TestKey4", "TestValue4", "label",
35+
eTag: new ETag("3ca43b3e-d544-4b0c-b3a2-e7a7284217a2"),
36+
contentType: "text"),
37+
};
38+
39+
[Fact]
40+
public async Task HealthCheckTests_ReturnsHealthyWhenInitialLoadIsCompleted()
41+
{
42+
var mockResponse = new Mock<Response>();
43+
var mockClient = new Mock<ConfigurationClient>(MockBehavior.Strict);
44+
45+
mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
46+
.Returns(new MockAsyncPageable(kvCollection));
47+
48+
var config = new ConfigurationBuilder()
49+
.AddAzureAppConfiguration(options =>
50+
{
51+
options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
52+
})
53+
.Build();
54+
55+
IHealthCheck healthCheck = new AzureAppConfigurationHealthCheck(config);
56+
57+
Assert.True(config["TestKey1"] == "TestValue1");
58+
var result = await healthCheck.CheckHealthAsync(new HealthCheckContext());
59+
Assert.Equal(HealthStatus.Healthy, result.Status);
60+
}
61+
62+
[Fact]
63+
public async Task HealthCheckTests_ReturnsUnhealthyWhenRefreshFailed()
64+
{
65+
IConfigurationRefresher refresher = null;
66+
var mockResponse = new Mock<Response>();
67+
var mockClient = new Mock<ConfigurationClient>(MockBehavior.Strict);
68+
69+
mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
70+
.Returns(new MockAsyncPageable(kvCollection))
71+
.Throws(new RequestFailedException(503, "Request failed."))
72+
.Returns(new MockAsyncPageable(Enumerable.Empty<ConfigurationSetting>().ToList()))
73+
.Returns(new MockAsyncPageable(Enumerable.Empty<ConfigurationSetting>().ToList()));
74+
75+
var config = new ConfigurationBuilder()
76+
.AddAzureAppConfiguration(options =>
77+
{
78+
options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
79+
options.MinBackoffDuration = TimeSpan.FromSeconds(2);
80+
options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator();
81+
options.ConfigureRefresh(refreshOptions =>
82+
{
83+
refreshOptions.RegisterAll()
84+
.SetRefreshInterval(TimeSpan.FromSeconds(1));
85+
});
86+
refresher = options.GetRefresher();
87+
})
88+
.Build();
89+
90+
IHealthCheck healthCheck = new AzureAppConfigurationHealthCheck(config);
91+
92+
var result = await healthCheck.CheckHealthAsync(new HealthCheckContext());
93+
Assert.Equal(HealthStatus.Healthy, result.Status);
94+
95+
// Wait for the refresh interval to expire
96+
Thread.Sleep(1000);
97+
98+
await refresher.TryRefreshAsync();
99+
result = await healthCheck.CheckHealthAsync(new HealthCheckContext());
100+
Assert.Equal(HealthStatus.Unhealthy, result.Status);
101+
102+
// Wait for client backoff to end
103+
Thread.Sleep(3000);
104+
105+
await refresher.RefreshAsync();
106+
result = await healthCheck.CheckHealthAsync(new HealthCheckContext());
107+
Assert.Equal(HealthStatus.Healthy, result.Status);
108+
}
109+
110+
[Fact]
111+
public async Task HealthCheckTests_RegisterAzureAppConfigurationHealthCheck()
112+
{
113+
var mockResponse = new Mock<Response>();
114+
var mockClient = new Mock<ConfigurationClient>(MockBehavior.Strict);
115+
116+
mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
117+
.Returns(new MockAsyncPageable(kvCollection));
118+
119+
var config = new ConfigurationBuilder()
120+
.AddAzureAppConfiguration(options =>
121+
{
122+
options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
123+
})
124+
.Build();
125+
126+
var services = new ServiceCollection();
127+
services.AddSingleton<IConfiguration>(config);
128+
services.AddLogging(); // add logging for health check service
129+
services.AddHealthChecks()
130+
.AddAzureAppConfiguration();
131+
var provider = services.BuildServiceProvider();
132+
var healthCheckService = provider.GetRequiredService<HealthCheckService>();
133+
134+
var result = await healthCheckService.CheckHealthAsync();
135+
Assert.Equal(HealthStatus.Healthy, result.Status);
136+
Assert.Contains(HealthCheckConstants.HealthCheckRegistrationName, result.Entries.Keys);
137+
Assert.Equal(HealthStatus.Healthy, result.Entries[HealthCheckConstants.HealthCheckRegistrationName].Status);
138+
}
139+
}
140+
}

0 commit comments

Comments
 (0)