From 224b3de104f46924727832f1f42554ac57d4e3e5 Mon Sep 17 00:00:00 2001 From: Jon Sagara Date: Tue, 28 Apr 2026 07:56:46 -0700 Subject: [PATCH] Added the ability to pass an additional inclusion predicate to the AddSerilog configuration method. --- Directory.Build.props | 2 +- ...ddSerilogToExistingILoggerFactoryResult.cs | 17 +++ .../ConfigureAzureFunctionsLoggingResult.cs | 10 -- .../SerilogAzureFunctionsExtensions.cs | 94 ------------- .../SerilogExtensions.cs | 132 +++++++++++++++++- 5 files changed, 149 insertions(+), 106 deletions(-) create mode 100644 src/Sagara.Core.Logging.Serilog/AddSerilogToExistingILoggerFactoryResult.cs delete mode 100644 src/Sagara.Core.Logging.Serilog/Azure/Functions/ConfigureAzureFunctionsLoggingResult.cs delete mode 100644 src/Sagara.Core.Logging.Serilog/Azure/Functions/SerilogAzureFunctionsExtensions.cs diff --git a/Directory.Build.props b/Directory.Build.props index 556ac2a..b06a2b9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ net8.0;net9.0;net10.0 - 5.4.3 + 5.4.4 5.4.0 5.4.0 Jon Sagara diff --git a/src/Sagara.Core.Logging.Serilog/AddSerilogToExistingILoggerFactoryResult.cs b/src/Sagara.Core.Logging.Serilog/AddSerilogToExistingILoggerFactoryResult.cs new file mode 100644 index 0000000..03dfa2c --- /dev/null +++ b/src/Sagara.Core.Logging.Serilog/AddSerilogToExistingILoggerFactoryResult.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Serilog.Extensions.Logging; + +namespace Sagara.Core.Logging.Serilog; + +/// +/// The result of configuring an Azure App Service or dotnet-isolated Azure Function to support logging with both +/// Serilog and Microsoft.Extensions.Logging (MEL). +/// Initially, the collections is empty. After building the host, the caller MUST call +/// +/// to populate it. +/// +/// The concrete instances retrieved from the +/// instance. +public record AddSerilogToExistingILoggerFactoryResult( + LoggerProviderCollection NonSerilogMELProviders); diff --git a/src/Sagara.Core.Logging.Serilog/Azure/Functions/ConfigureAzureFunctionsLoggingResult.cs b/src/Sagara.Core.Logging.Serilog/Azure/Functions/ConfigureAzureFunctionsLoggingResult.cs deleted file mode 100644 index d3715bc..0000000 --- a/src/Sagara.Core.Logging.Serilog/Azure/Functions/ConfigureAzureFunctionsLoggingResult.cs +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 37ae152..0000000 --- a/src/Sagara.Core.Logging.Serilog/Azure/Functions/SerilogAzureFunctionsExtensions.cs +++ /dev/null @@ -1,94 +0,0 @@ -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 built, 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); - } - } -} diff --git a/src/Sagara.Core.Logging.Serilog/SerilogExtensions.cs b/src/Sagara.Core.Logging.Serilog/SerilogExtensions.cs index dc5ebaf..9922928 100644 --- a/src/Sagara.Core.Logging.Serilog/SerilogExtensions.cs +++ b/src/Sagara.Core.Logging.Serilog/SerilogExtensions.cs @@ -1,10 +1,139 @@ -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Sagara.Core.Logging.Serilog; using Serilog; +using Serilog.Events; +using Serilog.Extensions.Logging; namespace Sagara.Core.Logging.Serilog; public static class SerilogExtensions { + /// + /// Configure Serilog and Microsoft.Extensions.Logging (MEL) for Azure App Service and dotnet-isolated Azure Function so that: + /// + /// + /// Serilog.ILogger log events are forwarded to all MEL providers EXCEPT SerilogLoggerProvider. + /// + /// + /// MEL log events are forwarded to all of Serilog's sinks. + /// + /// + /// Both Serilog.ILogger and MEL write log events to Azure Monitor (via OTel) without duplicates, and are viewable in Application Insights. + /// + /// + /// See also: . + /// + /// + /// NOTE: this is intended for Azure App Service and dotnet-isolated Azure Functions. + /// + /// The application builder. The type is not enforced so that we don't have to take a dependency + /// on the Azure Functions SDK, but it should be either a WebApplicationBuilder or a FunctionsApplicationBuilder. + /// The caller-provided a function that creates a Serilog LoggerConfiguration. + /// An optional predicate to add additional conditions for forwarding Serilog LogEvents + /// to MEL providers via WriteTo.Providers(). By default, only events with the "DirectSerilog" property are forwarded. If provided, this predicate's + /// return value will be ANDed together with the check for existence of the "DirectSerilog" property. + /// A result containing the empty collection of other MEL providers. The caller must populate it after building the host. + + public static AddSerilogToExistingILoggerFactoryResult AddSerilogToExistingILoggerFactory(this IHostApplicationBuilder builder, + Func createSerilogLoggerConfiguration, Func? additionalWriteToProvidersInclusionPredicate = null) + { + 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 => + { + // ONLY send log events that originated from Serilog.ILogger. + var isFromSerilog = le.Properties.ContainsKey(directSerilogPropertyName); + + // If the caller sent an additional predicate, apply that as well. The prime example of using this is to exclude + // "bad API middleware" Serilog logs from being forwarded to MEL, which probably get us blocked from using + // Application Insights. + // If the user didn't send an additional predicate, default to true so we don't automatically discard the event. + var additionalPredicateResult = additionalWriteToProvidersInclusionPredicate?.Invoke(le) ?? true; + + return isFromSerilog && additionalPredicateResult; + }) + .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 built, retrieve all non- instances + /// and add them to Serilog's "write to" providers retuned by . + /// Serilog will forward all directly written Serilog.ILogger log entries to the registered MEL providers. + /// + /// The Azure App Service or dotnet-isolated Azure Function's instance. + /// A collection of concrete instances that were + /// registered with the host, excluding . + public static void AddNonSerilogMELProvidersToSerilog(this IHost host, AddSerilogToExistingILoggerFactoryResult addSerilogResult) + { + Check.ThrowIfNull(host); + Check.ThrowIfNull(addSerilogResult); + + // 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) + { + addSerilogResult.NonSerilogMELProviders.AddProvider(loggerProvider); + } + } + /// /// Sets Serilog as the logging provider. /// @@ -22,6 +151,7 @@ public static class SerilogExtensions /// registered through the Microsoft.Extensions.Logging API. Normally, equivalent Serilog sinks are used in place of providers. /// Specify true to write events to all providers. /// The host application builder. + [Obsolete($"Obsolete. Use {nameof(AddSerilogToExistingILoggerFactory)} instead. It's additive instead of overwriting the existing ILoggerFactory.")] public static IHostApplicationBuilder UseSerilog(this IHostApplicationBuilder builder, Action configureLogger, bool preserveStaticLogger = false, bool writeToProviders = false) { ArgumentNullException.ThrowIfNull(builder);