From 4f9ca44c8845b794e80253f9973dc2aa811265a4 Mon Sep 17 00:00:00 2001 From: Jon Sagara Date: Sun, 26 Apr 2026 16:06:57 -0700 Subject: [PATCH] Add extension methods to simplify hooking up Serilog to a dotnet-isolated Azure Function. --- Directory.Build.props | 2 +- .../ConfigureAzureFunctionsLoggingResult.cs | 10 ++ .../SerilogAzureFunctionsExtensions.cs | 94 +++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 src/Sagara.Core.Logging.Serilog/Azure/Functions/ConfigureAzureFunctionsLoggingResult.cs create mode 100644 src/Sagara.Core.Logging.Serilog/Azure/Functions/SerilogAzureFunctionsExtensions.cs diff --git a/Directory.Build.props b/Directory.Build.props index 0d927c1..556ac2a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ net8.0;net9.0;net10.0 - 5.4.2 + 5.4.3 5.4.0 5.4.0 Jon Sagara diff --git a/src/Sagara.Core.Logging.Serilog/Azure/Functions/ConfigureAzureFunctionsLoggingResult.cs b/src/Sagara.Core.Logging.Serilog/Azure/Functions/ConfigureAzureFunctionsLoggingResult.cs new file mode 100644 index 0000000..d3715bc --- /dev/null +++ b/src/Sagara.Core.Logging.Serilog/Azure/Functions/ConfigureAzureFunctionsLoggingResult.cs @@ -0,0 +1,10 @@ +using Serilog.Extensions.Logging; + +namespace Sagara.Core.Logging.Serilog.Azure.Functions; + +/// +/// +/// +/// +public record ConfigureAzureFunctionsLoggingResult( + LoggerProviderCollection NonSerilogMELProviders); diff --git a/src/Sagara.Core.Logging.Serilog/Azure/Functions/SerilogAzureFunctionsExtensions.cs b/src/Sagara.Core.Logging.Serilog/Azure/Functions/SerilogAzureFunctionsExtensions.cs new file mode 100644 index 0000000..347623a --- /dev/null +++ b/src/Sagara.Core.Logging.Serilog/Azure/Functions/SerilogAzureFunctionsExtensions.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Extensions.Logging; + +namespace Sagara.Core.Logging.Serilog.Azure.Functions; + +public static class SerilogAzureFunctionsExtensions +{ + public static ConfigureAzureFunctionsLoggingResult ConfigureAzureFunctionsLogging(this IHostApplicationBuilder builder, Func createSerilogLoggerConfiguration) + { + Check.ThrowIfNull(builder); + Check.ThrowIfNull(createSerilogLoggerConfiguration); + + // The "DirectSerilog" property tags events that originate from Serilog directly (DI / static); WriteTo.Providers() + // forwards only these to other MEL providers, so MEL log events that get sent to Serilog aren't again forwarded + // back to MEL. This eliniates duplicates in Application Insights. + // NOTE: the random suffix has no meaning; it' s just to ensure the property name is unique and won't collide + // with any user-defined properties. + var directSerilogPropertyName = $"DirectSerilog_{RandomString.GenerateAlphanumeric(length: 16)}"; + + // This collection lets Serilog write log events from Serilog.ILogger to through other dynamically-added MEL + // ILoggerProviders (but not SerilogLoggerProvider, as that creates circular references and crashes the app). + // Justification: This lives for the life of the process as a registered singleton, and the process will dispose of + // it upon shutdown. +#pragma warning disable CA2000 // Unnecessary assignment of a value + var nonSerilogMELProviders = new LoggerProviderCollection(); +#pragma warning restore CA2000 // Unnecessary assignment of a value + + // Always write Serilog.ILogger log evnets to all other non-Serilog ILoggerProviders (most importantly, OTel). + // For logs that originate from MEL (and thus don't have the "DirectSerilog" property), we assume they've + // already reached OTel, and we don't forward them again. + var underlyingLogger = createSerilogLoggerConfiguration() + .WriteTo.Logger(lc => lc + .Filter.ByIncludingOnly(le => le.Properties.ContainsKey(directSerilogPropertyName)) + .WriteTo.Providers(nonSerilogMELProviders) + ) + .CreateLogger(); + + // Create a Serilog logger that has the "DirectSerilog" property, so that the bridge to OTel can identify + // events that originate from Serilog directly (DI / static). + // This is also the Serilog.ILogger instance that register with DI for injecting, and also for using as + // static logging properties via Log.Logger.ForContext(). The "DirectSerilog" property will be included on + // all logs from that logger, so they will be forwarded to OTel and thus to Azure Monitor / App Insights. + // Logs that don't have the "DirectSerilog" property are assumed to have come through MEL (and thus already + // reached OTel via OpenTelemetryLoggerProvider) and are not forwarded again to avoid double-writing in Azure + // Monitor / App Insights. + // "true" has no meaning. We only care whether or not the property is present on the LogEvent. + Log.Logger = underlyingLogger.ForContext(propertyName: directSerilogPropertyName, value: true); + + // Now that we have the underlying Serilog logger, we can add the SerilogLoggerProvider to MEL, which allows a + // MEL ILogger to write to Serilog's non-OTel sinks (i.e., Files). + // We can't call builder.Services.AddSerilog() or builder.Host.UseSerilog() because that would overwrite the + // Functions host's ILoggerFactory and thus break communication between the host and the worker. + builder.Logging.AddSerilog(underlyingLogger, dispose: true); + + // Register the collection of other MEL providers and the Serilog logger itself for DI. Serilog needs the providers + // collection to know what to forward to. + builder.Services.AddSingleton(nonSerilogMELProviders); + + // Log.Logger is what will be used for static logging via Log.ForContext(). + builder.Services.AddSingleton(Log.Logger); + + // Return the collection of non-Serilog MEL providers so that the caller can populate it after building the host. + return new(NonSerilogMELProviders: nonSerilogMELProviders); + } + + /// + /// After the host is build, retrieve all non- instances + /// and register them with Serilog. This enables Serilog to forward all Serilog.ILogger log entries to the registered + /// MEL providers. + /// + /// The dotnet-isolated Azure Function's instance. + /// The result of configuring Azure Functions logging for Serilog and MEL. + public static void RegisterNonSerilogMELProvidersWithSerilog(this IHost host, ConfigureAzureFunctionsLoggingResult configureAzureFuncLoggingResult) + { + Check.ThrowIfNull(host); + Check.ThrowIfNull(configureAzureFuncLoggingResult); + + // After building the host, we can pull out any MEL ILoggerProviders that were registered and add them to the + // LoggerProviderCollection (except SerilogLoggerProvider, which causes circular references and crashes the process). + // This is what allows Serilog to write to any MEL providers, including to Azure Monitor for viewing in Application Insights. + var loggerProviders = host.Services + .GetServices() + .Where(lp => lp.GetType() != typeof(SerilogLoggerProvider)) + .ToArray(); + + foreach (var loggerProvider in loggerProviders) + { + configureAzureFuncLoggingResult.NonSerilogMELProviders.AddProvider(loggerProvider); + } + } +}