diff --git a/src/Umbraco.Core/Configuration/Models/LoggingSettings.cs b/src/Umbraco.Core/Configuration/Models/LoggingSettings.cs index 0489d895b874..33e4ed299e6d 100644 --- a/src/Umbraco.Core/Configuration/Models/LoggingSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/LoggingSettings.cs @@ -2,6 +2,7 @@ // See LICENSE for more details. using System.ComponentModel; +using Umbraco.Cms.Core.Logging; namespace Umbraco.Cms.Core.Configuration.Models; @@ -13,6 +14,8 @@ public class LoggingSettings { internal const string StaticMaxLogAge = "1.00:00:00"; // TimeSpan.FromHours(24); internal const string StaticDirectory = Constants.SystemDirectories.LogFiles; + internal const string StaticFileNameFormat = LoggingConfiguration.DefaultLogFileNameFormat; + internal const string StaticFileNameFormatArguments = "MachineName"; /// /// Gets or sets a value for the maximum age of a log file. @@ -31,4 +34,25 @@ public class LoggingSettings /// [DefaultValue(StaticDirectory)] public string Directory { get; set; } = StaticDirectory; + + /// + /// Gets or sets the file name format to use for log files. + /// + /// + /// The file name format. + /// + [DefaultValue(StaticFileNameFormat)] + public string FileNameFormat { get; set; } = StaticFileNameFormat; + + /// + /// Gets or sets the file name format arguments to use for log files. + /// + /// + /// The file name format arguments as a comma delimited string of accepted values. + /// + /// + /// Accepted values for format arguments are: MachineName, EnvironmentName. + /// + [DefaultValue(StaticFileNameFormatArguments)] + public string FileNameFormatArguments { get; set; } = StaticFileNameFormatArguments; } diff --git a/src/Umbraco.Core/Configuration/Models/Validation/LoggingSettingsValidator.cs b/src/Umbraco.Core/Configuration/Models/Validation/LoggingSettingsValidator.cs new file mode 100644 index 000000000000..1670a897a14e --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/Validation/LoggingSettingsValidator.cs @@ -0,0 +1,51 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Logging; + +namespace Umbraco.Cms.Core.Configuration.Models.Validation; + +/// +/// Validator for configuration representated as . +/// +public class LoggingSettingsValidator : ConfigurationValidatorBase, IValidateOptions +{ + /// + public ValidateOptionsResult Validate(string? name, LoggingSettings options) + { + if (!ValidateFileNameFormatArgument(options.FileNameFormat, options.FileNameFormatArguments, out var message)) + { + return ValidateOptionsResult.Fail(message); + } + + + return ValidateOptionsResult.Success; + } + + private bool ValidateFileNameFormatArgument(string fileNameFormat, string fileNameFormatArguments, out string message) + { + var fileNameFormatArgumentsAsArray = fileNameFormatArguments + .Split([','], StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .ToArray(); + if (fileNameFormatArgumentsAsArray.Any(x => LoggingConfiguration.SupportedFileNameFormatArguments.Contains(x) is false)) + { + message = $"The file name arguments '{string.Join(",", fileNameFormatArgumentsAsArray)}' contain one or more values that aren't in the supported list of values '{string.Join(",", LoggingConfiguration.SupportedFileNameFormatArguments)}'."; + return false; + } + + try + { + _ = string.Format(fileNameFormat, fileNameFormatArgumentsAsArray); + } + catch (FormatException) + { + message = $"The provided file name format '{fileNameFormat}' could not be used with the provided arguments '{string.Join(",", fileNameFormatArgumentsAsArray)}'."; + return false; + } + + message = string.Empty; + return true; + } +} diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index c7de197d011d..1e9a93abf23a 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -43,6 +43,7 @@ public static IUmbracoBuilder AddConfiguration(this IUmbracoBuilder builder) builder.Services.AddSingleton, ContentSettingsValidator>(); builder.Services.AddSingleton, GlobalSettingsValidator>(); builder.Services.AddSingleton, HealthChecksSettingsValidator>(); + builder.Services.AddSingleton, LoggingSettingsValidator>(); builder.Services.AddSingleton, RequestHandlerSettingsValidator>(); builder.Services.AddSingleton, UnattendedSettingsValidator>(); builder.Services.AddSingleton, SecuritySettingsValidator>(); diff --git a/src/Umbraco.Core/Logging/ILoggingConfiguration.cs b/src/Umbraco.Core/Logging/ILoggingConfiguration.cs index 662ee7891c84..db379201bf8e 100644 --- a/src/Umbraco.Core/Logging/ILoggingConfiguration.cs +++ b/src/Umbraco.Core/Logging/ILoggingConfiguration.cs @@ -3,7 +3,17 @@ namespace Umbraco.Cms.Core.Logging; public interface ILoggingConfiguration { /// - /// Gets the physical path where logs are stored + /// Gets the physical path where logs are stored. /// string LogDirectory { get; } + + /// + /// Gets the file name format for the log files. + /// + string LogFileNameFormat { get; } + + /// + /// Gets the file name format arguments for the log files. + /// + string[] GetLogFileNameFormatArguments(); } diff --git a/src/Umbraco.Core/Logging/LoggingConfiguration.cs b/src/Umbraco.Core/Logging/LoggingConfiguration.cs index d2a24d24a95a..b6d68934824d 100644 --- a/src/Umbraco.Core/Logging/LoggingConfiguration.cs +++ b/src/Umbraco.Core/Logging/LoggingConfiguration.cs @@ -1,9 +1,73 @@ namespace Umbraco.Cms.Core.Logging; +/// +/// Implements to provide configuration for logging to files. +/// public class LoggingConfiguration : ILoggingConfiguration { - public LoggingConfiguration(string logDirectory) => - LogDirectory = logDirectory ?? throw new ArgumentNullException(nameof(logDirectory)); + /// + /// The default log file name format. + /// + public const string DefaultLogFileNameFormat = "UmbracoTraceLog.{0}..json"; + /// + /// The default log file name format arguments. + /// + public const string DefaultLogFileNameFormatArguments = MachineNameFileFormatArgument; + + /// + /// The collection of supported file name format arguments. + /// + public static readonly string[] SupportedFileNameFormatArguments = + { + MachineNameFileFormatArgument, + EnvironmentNameFileFormatArgument, + }; + + private readonly string _logFileNameFormatArguments; + + private const string MachineNameFileFormatArgument = "MachineName"; + private const string EnvironmentNameFileFormatArgument = "EnvironmentName"; + + /// + /// Initializes a new instance of the class with the default log file name format and arguments. + /// + /// The log file directory. + public LoggingConfiguration(string logDirectory) + : this(logDirectory, DefaultLogFileNameFormat, DefaultLogFileNameFormatArguments) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The log file directory. + /// The log file name format. + /// The log file name format arguments as a comma delimited string. + public LoggingConfiguration(string logDirectory, string logFileNameFormat, string logFileNameFormatArguments) + { + LogDirectory = logDirectory; + LogFileNameFormat = logFileNameFormat; + _logFileNameFormatArguments = logFileNameFormatArguments; + } + + /// public string LogDirectory { get; } + + /// + public string LogFileNameFormat { get; } + + /// + public string[] GetLogFileNameFormatArguments() => _logFileNameFormatArguments.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .Select(GetValue) + .ToArray(); + + private static string GetValue(string arg) => + arg switch + { + MachineNameFileFormatArgument => Environment.MachineName, + EnvironmentNameFileFormatArgument => Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production", + _ => string.Empty, + }; } diff --git a/src/Umbraco.Infrastructure/Logging/Serilog/LoggerConfigExtensions.cs b/src/Umbraco.Infrastructure/Logging/Serilog/LoggerConfigExtensions.cs index b983f3e663bf..9d3213bd2a9b 100644 --- a/src/Umbraco.Infrastructure/Logging/Serilog/LoggerConfigExtensions.cs +++ b/src/Umbraco.Infrastructure/Logging/Serilog/LoggerConfigExtensions.cs @@ -109,7 +109,7 @@ public static LoggerConfiguration MinimalConfiguration( .Enrich.FromLogContext(); // allows us to dynamically enrich logConfig.WriteTo.UmbracoFile( - path: umbracoFileConfiguration.GetPath(loggingConfiguration.LogDirectory), + path: umbracoFileConfiguration.GetPath(loggingConfiguration.LogDirectory, loggingConfiguration.LogFileNameFormat, loggingConfiguration.GetLogFileNameFormatArguments()), fileSizeLimitBytes: umbracoFileConfiguration.FileSizeLimitBytes, restrictedToMinimumLevel: umbracoFileConfiguration.RestrictedToMinimumLevel, rollingInterval: umbracoFileConfiguration.RollingInterval, diff --git a/src/Umbraco.Infrastructure/Logging/Serilog/UmbracoFileConfiguration.cs b/src/Umbraco.Infrastructure/Logging/Serilog/UmbracoFileConfiguration.cs index b83c76fbd367..5d650dac07c2 100644 --- a/src/Umbraco.Infrastructure/Logging/Serilog/UmbracoFileConfiguration.cs +++ b/src/Umbraco.Infrastructure/Logging/Serilog/UmbracoFileConfiguration.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Serilog; using Serilog.Events; +using Umbraco.Cms.Core.Logging; namespace Umbraco.Cms.Infrastructure.Logging.Serilog; @@ -43,5 +44,8 @@ public UmbracoFileConfiguration(IConfiguration configuration) public int RetainedFileCountLimit { get; set; } = 31; public string GetPath(string logDirectory) => - Path.Combine(logDirectory, $"UmbracoTraceLog.{Environment.MachineName}..json"); + GetPath(logDirectory, LoggingConfiguration.DefaultLogFileNameFormat, Environment.MachineName); + + public string GetPath(string logDirectory, string fileNameFormat, params string[] fileNameArgs) => + Path.Combine(logDirectory, string.Format(fileNameFormat, fileNameArgs)); } diff --git a/src/Umbraco.Web.Common/Extensions/ServiceCollectionExtensions.cs b/src/Umbraco.Web.Common/Extensions/ServiceCollectionExtensions.cs index e776270d757a..69c702c021b8 100644 --- a/src/Umbraco.Web.Common/Extensions/ServiceCollectionExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/ServiceCollectionExtensions.cs @@ -42,7 +42,7 @@ public static IServiceCollection AddLogger( LoggingSettings loggerSettings = GetLoggerSettings(configuration); var loggingDir = loggerSettings.GetAbsoluteLoggingPath(hostEnvironment); - ILoggingConfiguration loggingConfig = new LoggingConfiguration(loggingDir); + ILoggingConfiguration loggingConfig = new LoggingConfiguration(loggingDir, loggerSettings.FileNameFormat, loggerSettings.FileNameFormatArguments); var umbracoFileConfiguration = new UmbracoFileConfiguration(configuration); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/Validation/LoggingSettingsValidatorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/Validation/LoggingSettingsValidatorTests.cs new file mode 100644 index 000000000000..a33321abe5ed --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/Validation/LoggingSettingsValidatorTests.cs @@ -0,0 +1,51 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Options; +using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Configuration.Models.Validation; +using Umbraco.Cms.Core.Logging; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models.Validation +{ + [TestFixture] + public class LoggingSettingsValidatorTests + { + [Test] + public void Returns_Success_ForValid_Configuration() + { + var validator = new LoggingSettingsValidator(); + LoggingSettings options = BuildLoggingSettings(); + ValidateOptionsResult result = validator.Validate("settings", options); + Assert.True(result.Succeeded); + } + + [Test] + public void Returns_Fail_For_Configuration_With_Invalid_FileNameFormatArguments() + { + var validator = new LoggingSettingsValidator(); + LoggingSettings options = BuildLoggingSettings(fileNameFormatArguments: "MachineName,Invalid"); + ValidateOptionsResult result = validator.Validate("settings", options); + Assert.False(result.Succeeded); + } + + [Test] + public void Returns_Fail_For_Configuration_With_Invalid_FileNameFormat() + { + var validator = new LoggingSettingsValidator(); + LoggingSettings options = BuildLoggingSettings(fileNameFormat: "InvalidAsTooManyPlaceholders_{0}_{1}"); + ValidateOptionsResult result = validator.Validate("settings", options); + Assert.False(result.Succeeded); + } + + private static LoggingSettings BuildLoggingSettings( + string fileNameFormat = LoggingConfiguration.DefaultLogFileNameFormat, + string fileNameFormatArguments = LoggingConfiguration.DefaultLogFileNameFormatArguments) => + new LoggingSettings + { + FileNameFormat = fileNameFormat, + FileNameFormatArguments = fileNameFormatArguments, + }; + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Logging/LoggingConfigurationTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Logging/LoggingConfigurationTests.cs new file mode 100644 index 000000000000..d446974f021c --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Logging/LoggingConfigurationTests.cs @@ -0,0 +1,25 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Logging; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Logging; + +[TestFixture] +public class LoggingConfigurationTests +{ + [SetUp] + public void SetUp() => Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production"); + + [Test] + public void Can_Get_Supported_Log_File_Name_Format_Arguments() + { + var config = new LoggingConfiguration("c:\\logs\\", "UmbracoLogFile_{0}_{1}..json", "MachineName,EnvironmentName"); + var result = config.GetLogFileNameFormatArguments(); + + Assert.AreEqual(2, result.Length); + + var expectedMachineName = Environment.MachineName; + var expectedEnvironmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + Assert.AreEqual(expectedMachineName, result[0]); + Assert.AreEqual(expectedEnvironmentName, result[1]); + } +}