Skip to content

Commit 9614ca4

Browse files
authored
Include HealthChecks from ScriptHost scope (#11410)
* Include HealthChecks from ScriptHost scope * Update release_notes.md * Update release_notes.md * Fix unit test * Address PR comments * Fix tests
1 parent 64bc0b3 commit 9614ca4

File tree

11 files changed

+538
-27
lines changed

11 files changed

+538
-27
lines changed

release_notes.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
- Add JitTrace Files for v4.1044
99
- Remove duplicate function names from sync triggers payload(#11371)
1010
- Avoid emitting empty tag values for health check metrics (#11393)
11+
- Run health checks from the active ScriptHost (#11410)
12+
- Publish health check metrics to legacy AppInsights SDK (#11365)
13+
- Fix tag filter for health check live & ready endpoints (#11363)
1114
- Functions host to take a customer specified port in Custom Handler scenario (#11408)
1215
- Updated to version 1.5.8 of Microsoft.Azure.AppService.Middleware.Functions (#11416)
1316
- Enabling worker indexing for Logic Apps app kind behind an enviornment setting

src/WebJobs.Script.WebHost/DependencyInjection/ServiceProviderExtensions.cs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// These extensions are based on Orchard (https://github.com/OrchardCMS/OrchardCore)
1+
// These extensions are based on Orchard (https://github.com/OrchardCMS/OrchardCore)
22
// BSD 3 - Clause License
33
// https://opensource.org/licenses/BSD-3-Clause
44
//
@@ -7,11 +7,12 @@
77
using System;
88
using System.Collections.Generic;
99
using System.Linq;
10-
using System.Text;
1110
using Microsoft.AspNetCore.Hosting;
1211
using Microsoft.Extensions.DependencyInjection;
12+
using Microsoft.Extensions.Diagnostics.HealthChecks;
1313
using Microsoft.Extensions.Hosting;
1414
using Microsoft.Extensions.Logging;
15+
using Microsoft.Extensions.Options;
1516

1617
namespace Microsoft.Azure.WebJobs.Script.WebHost.DependencyInjection
1718
{
@@ -20,13 +21,16 @@ public static class ServiceProviderExtensions
2021
/// <summary>
2122
/// Things we don't want to copy down to child containers because...
2223
/// </summary>
23-
private static readonly HashSet<Type> ChildContainerIgnoredTypes = new()
24-
{
25-
typeof(IStartupFilter), // This would re-add middlewares to the host pipeline
24+
private static readonly HashSet<Type> ChildContainerIgnoredTypes =
25+
[
26+
typeof(IStartupFilter), // This would re-add middleware to the host pipeline
2627
typeof(IManagedHostedService), // These shouldn't be instantiated twice
2728
typeof(IHostedService), // These shouldn't be instantiated twice
28-
typeof(ILoggerProvider), // These shouldn't be instantiated twice
29-
};
29+
typeof(ILoggerProvider), // These shouldn't be instantiated twice
30+
typeof(HealthCheckService), // Child container should instantiate its own.
31+
typeof(IConfigureOptions<HealthCheckServiceOptions>), // Child container should instantiate its own.
32+
typeof(IPostConfigureOptions<HealthCheckServiceOptions>), // Child container should instantiate its own.
33+
];
3034

3135
/// <summary>
3236
/// Creates a child container.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Concurrent;
6+
using System.Collections.Generic;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using Microsoft.Extensions.DependencyInjection;
10+
using Microsoft.Extensions.Diagnostics.HealthChecks;
11+
using Microsoft.Extensions.Logging;
12+
13+
#nullable enable
14+
15+
namespace Microsoft.Azure.WebJobs.Script.Diagnostics.HealthChecks
16+
{
17+
/// <summary>
18+
/// Service to manage health checks. This service supports both the WebHost and ScriptHost health checks.
19+
/// </summary>
20+
/// <remarks>
21+
/// The approach taken is to perform individual health checks on both the WebHost and ScriptHost, then merge the results.
22+
/// This is to ensure individual health checks run in the correct service scope (WebHost vs ScriptHost) from where they
23+
/// are registered.
24+
/// </remarks>
25+
public sealed partial class DynamicHealthCheckService : HealthCheckService
26+
{
27+
private readonly ConcurrentDictionary<string, DateTimeOffset> _conflictLogBackoff = new();
28+
29+
private readonly HealthCheckService _webHostCheck;
30+
private readonly IScriptHostManager _manager;
31+
private readonly ILogger _logger;
32+
33+
public DynamicHealthCheckService(
34+
HealthCheckService webHostCheck,
35+
IScriptHostManager manager,
36+
ILogger<DynamicHealthCheckService> logger)
37+
{
38+
_webHostCheck = webHostCheck ?? throw new ArgumentNullException(nameof(webHostCheck));
39+
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
40+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
41+
}
42+
43+
public override async Task<HealthReport> CheckHealthAsync(
44+
Func<HealthCheckRegistration, bool>? predicate,
45+
CancellationToken cancellationToken = default)
46+
{
47+
Task<HealthReport> webHostReport = _webHostCheck.CheckHealthAsync(predicate, cancellationToken);
48+
Task<HealthReport?> scriptHostReport = CheckScriptHostHealthAsync(predicate, cancellationToken);
49+
return MergeReports(await webHostReport, await scriptHostReport);
50+
}
51+
52+
private HealthReport MergeReports(HealthReport left, HealthReport? right)
53+
{
54+
if (right == null)
55+
{
56+
return left;
57+
}
58+
59+
// Merge the entries of both reports. If there are any duplicate keys, keep the left one.
60+
Dictionary<string, HealthReportEntry> entries = new(left.Entries);
61+
foreach ((string key, HealthReportEntry value) in right.Entries)
62+
{
63+
if (!entries.TryAdd(key, value))
64+
{
65+
bool log = true;
66+
if (_conflictLogBackoff.TryGetValue(key, out DateTimeOffset lastLogTime))
67+
{
68+
// ensure we log this only once per hour
69+
log = DateTimeOffset.UtcNow - lastLogTime > TimeSpan.FromHours(1);
70+
}
71+
72+
if (log)
73+
{
74+
_conflictLogBackoff[key] = DateTimeOffset.UtcNow;
75+
Log.DuplicateHealthCheckEntry(_logger, key);
76+
}
77+
}
78+
}
79+
80+
// take the worst status.
81+
HealthStatus status = left.Status < right.Status
82+
? left.Status : right.Status;
83+
84+
// take the longest duration.
85+
TimeSpan duration = left.TotalDuration > right.TotalDuration
86+
? left.TotalDuration : right.TotalDuration;
87+
88+
return new(entries, status, duration);
89+
}
90+
91+
private async Task<HealthReport?> CheckScriptHostHealthAsync(
92+
Func<HealthCheckRegistration, bool>? predicate,
93+
CancellationToken cancellationToken = default)
94+
{
95+
HealthCheckService? scriptHostCheck = _manager.Services.GetService<HealthCheckService>();
96+
if (scriptHostCheck is null)
97+
{
98+
Log.ScriptHostNoHealthCheckService(_logger);
99+
return null;
100+
}
101+
102+
return await scriptHostCheck.CheckHealthAsync(predicate, cancellationToken);
103+
}
104+
105+
private static partial class Log
106+
{
107+
[LoggerMessage(0, LogLevel.Debug, "Script host does not have a health check service. Skipping script host health checks.")]
108+
public static partial void ScriptHostNoHealthCheckService(ILogger logger);
109+
110+
[LoggerMessage(1, LogLevel.Warning, "Duplicate health check entry '{HealthCheck}' found when merging health check reports. Keeping the first entry.")]
111+
public static partial void DuplicateHealthCheckEntry(ILogger logger, string healthCheck);
112+
}
113+
}
114+
}

src/WebJobs.Script/Diagnostics/HealthChecks/HealthCheckExtensions.cs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,38 @@ public static IHealthChecksBuilder AddWebJobsScriptHealthChecks(this IHealthChec
2626
builder
2727
.AddWebHostHealthCheck()
2828
.AddScriptHostHealthCheck()
29-
.AddTelemetryPublisher(HealthCheckTags.Liveness, HealthCheckTags.Readiness);
29+
.AddTelemetryPublisher(HealthCheckTags.Liveness, HealthCheckTags.Readiness)
30+
.UseDynamicHealthCheckService();
31+
return builder;
32+
}
33+
34+
/// <summary>
35+
/// Replaces the default <see cref="HealthCheckService"/> with a <see cref="DynamicHealthCheckService"/>.
36+
/// </summary>
37+
/// <param name="builder">The builder to register health checks with.</param>
38+
/// <returns>The original builder, for call chaining.</returns>
39+
/// <exception cref="InvalidOperationException">If IServiceCollection.AddHealthChecks() is not called first.</exception>
40+
public static IHealthChecksBuilder UseDynamicHealthCheckService(this IHealthChecksBuilder builder)
41+
{
42+
ArgumentNullException.ThrowIfNull(builder);
43+
44+
// AddHealthChecks() must be called first to register the default HealthCheckService.
45+
// We will then remove the registration and replace it with our own.
46+
// We use the existing registration to manually create an instance of the service.
47+
ServiceDescriptor descriptor = builder.Services.Where(x => x.ServiceType == typeof(HealthCheckService))
48+
.FirstOrDefault()
49+
?? throw new InvalidOperationException(
50+
$"Ensure IServiceCollection.AddHealthChecks() is called before {nameof(UseDynamicHealthCheckService)}.");
51+
52+
builder.Services.Remove(descriptor);
53+
builder.Services.AddSingleton<HealthCheckService, DynamicHealthCheckService>(sp =>
54+
{
55+
// Manually create the existing HealthCheckService instance so we can wrap it.
56+
// We don't want to have to re-implement its logic ourselves.
57+
HealthCheckService webHost = (HealthCheckService)sp.CreateInstance(descriptor);
58+
return ActivatorUtilities.CreateInstance<DynamicHealthCheckService>(sp, webHost);
59+
});
60+
3061
return builder;
3162
}
3263

src/WebJobs.Script/Extensions/IServiceProviderExtensions.cs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the MIT License. See License.txt in the project root for license information.
33

44
using System;
@@ -36,5 +36,35 @@ public static TService GetScriptHostServiceOrNull<TService>(this IServiceProvide
3636

3737
return null;
3838
}
39+
40+
/// <summary>
41+
/// Creates an instance of the service described by the <see cref="ServiceDescriptor"/>.
42+
/// This method does not necessarily respect the service lifetime, so be cautious when using it.
43+
/// </summary>
44+
/// <param name="serviceProvider">The service provider to query.</param>
45+
/// <param name="descriptor">The descriptor to instantiate a service from.</param>
46+
/// <returns>The instantiated service.</returns>
47+
public static object CreateInstance(this IServiceProvider serviceProvider, ServiceDescriptor descriptor)
48+
{
49+
ArgumentNullException.ThrowIfNull(serviceProvider);
50+
ArgumentNullException.ThrowIfNull(descriptor);
51+
52+
if (descriptor.ImplementationType is { } type)
53+
{
54+
return ActivatorUtilities.GetServiceOrCreateInstance(serviceProvider, type);
55+
}
56+
57+
if (descriptor.ImplementationInstance is { } instance)
58+
{
59+
return instance;
60+
}
61+
62+
if (descriptor.ImplementationFactory is { } factory)
63+
{
64+
return factory(serviceProvider);
65+
}
66+
67+
throw new ArgumentException($"Could not get service for descriptor.", nameof(descriptor));
68+
}
3969
}
4070
}

src/WebJobs.Script/Host/IScriptHostManager.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the MIT License. See License.txt in the project root for license information.
33

44
using System;
@@ -19,8 +19,16 @@ public interface IScriptHostManager
1919
/// </summary>
2020
event EventHandler<ActiveHostChangedEventArgs> ActiveHostChanged;
2121

22+
/// <summary>
23+
/// Gets the current state of the script host.
24+
/// </summary>
2225
ScriptHostState State { get; }
2326

27+
/// <summary>
28+
/// Gets the services from the current script host.
29+
/// </summary>
30+
IServiceProvider Services { get; }
31+
2432
/// <summary>
2533
/// Gets the last host <see cref="Exception"/> that has occurred.
2634
/// </summary>

test/WebJobs.Script.Tests.Shared/TestHelpers.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the MIT License. See License.txt in the project root for license information.
33

44
using System;
@@ -654,6 +654,8 @@ event EventHandler IScriptHostManager.HostInitializing
654654

655655
Exception IScriptHostManager.LastError => throw new NotImplementedException();
656656

657+
IServiceProvider IScriptHostManager.Services => this;
658+
657659
public void OnActiveHostChanged()
658660
{
659661
ActiveHostChanged?.Invoke(this, new ActiveHostChangedEventArgs(null, null));

0 commit comments

Comments
 (0)