From b7bcb771676e2ff8a7dba73ce4f4260f80bb72f9 Mon Sep 17 00:00:00 2001 From: Victor Hurdugaci Date: Mon, 18 Jul 2016 14:49:49 -0700 Subject: [PATCH] A logger for Azure WebApps --- Logging.sln | 32 ++- .../Internal/NullLogger.cs | 30 +++ .../Internal/NullLoggerProvider.cs | 23 ++ .../Internal/NullScope.cs | 24 ++ ...AzureWebAppDiagnosticsFactoryExtensions.cs | 29 +++ .../AzureWebAppDiagnosticsLoggerProvider.cs | 58 +++++ .../Internal/BackgroundSink.cs | 110 +++++++++ .../Internal/FileLoggerProvider.cs | 72 ++++++ .../Internal/IWebAppContext.cs | 21 ++ .../Internal/IWebAppLogConfigurationReader.cs | 24 ++ .../Internal/SerilogLoggerProvider.cs | 125 +++++++++++ .../Internal/WebAppContext.cs | 34 +++ .../Internal/WebAppLogConfiguration.cs | 67 ++++++ .../Internal/WebAppLogConfigurationBuilder.cs | 114 ++++++++++ .../Internal/WebAppLogConfigurationReader.cs | 149 +++++++++++++ ...sions.Logging.AzureWebAppDiagnostics.xproj | 17 ++ .../Properties/AssemblyInfo.cs | 11 + .../project.json | 37 ++++ src/Microsoft.Extensions.Logging/Logger.cs | 12 +- .../BackgroundSinkTests.cs | 115 ++++++++++ .../LogConfigurationReaderTests.cs | 208 ++++++++++++++++++ ....Logging.AzureWebAppDiagnostics.Test.xproj | 21 ++ .../SerilogLoggerProviderTests.cs | 208 ++++++++++++++++++ .../SettingsFileContent.cs | 18 ++ .../TestSink.cs | 25 +++ .../project.json | 30 +++ 26 files changed, 1603 insertions(+), 11 deletions(-) create mode 100644 src/Microsoft.Extensions.Logging.Abstractions/Internal/NullLogger.cs create mode 100644 src/Microsoft.Extensions.Logging.Abstractions/Internal/NullLoggerProvider.cs create mode 100644 src/Microsoft.Extensions.Logging.Abstractions/Internal/NullScope.cs create mode 100644 src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsFactoryExtensions.cs create mode 100644 src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsLoggerProvider.cs create mode 100644 src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/BackgroundSink.cs create mode 100644 src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/FileLoggerProvider.cs create mode 100644 src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/IWebAppContext.cs create mode 100644 src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/IWebAppLogConfigurationReader.cs create mode 100644 src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/SerilogLoggerProvider.cs create mode 100644 src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppContext.cs create mode 100644 src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfiguration.cs create mode 100644 src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfigurationBuilder.cs create mode 100644 src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfigurationReader.cs create mode 100644 src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.xproj create mode 100644 src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Properties/AssemblyInfo.cs create mode 100644 src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/project.json create mode 100644 test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/BackgroundSinkTests.cs create mode 100644 test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/LogConfigurationReaderTests.cs create mode 100644 test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test.xproj create mode 100644 test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/SerilogLoggerProviderTests.cs create mode 100644 test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/SettingsFileContent.cs create mode 100644 test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/TestSink.cs create mode 100644 test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/project.json diff --git a/Logging.sln b/Logging.sln index b4ba3fd3..1ef4526a 100644 --- a/Logging.sln +++ b/Logging.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 +VisualStudioVersion = 14.0.25420.1 MinimumVisualStudioVersion = 10.0.40219.1 Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.Extensions.Logging", "src\Microsoft.Extensions.Logging\Microsoft.Extensions.Logging.xproj", "{19D1B6C5-8A62-4387-8816-C54874D1DF5F}" EndProject @@ -34,6 +34,10 @@ Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.Extensions.Loggin EndProject Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.Extensions.Logging.EventSource.Test", "test\Microsoft.Extensions.Logging.EventSource.Test\Microsoft.Extensions.Logging.EventSource.Test.xproj", "{F3B898C3-D441-4207-A92B-420D6E73CA5D}" EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.Extensions.Logging.AzureWebAppDiagnostics", "src\Microsoft.Extensions.Logging.AzureWebAppDiagnostics\Microsoft.Extensions.Logging.AzureWebAppDiagnostics.xproj", "{854133D5-6252-4A0A-B682-BDBB83B62AE6}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test", "test\Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test\Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test.xproj", "{B4A43221-DE95-47BB-A2D4-2DC761FC9419}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -190,6 +194,30 @@ Global {F3B898C3-D441-4207-A92B-420D6E73CA5D}.Release|Mixed Platforms.Build.0 = Release|Any CPU {F3B898C3-D441-4207-A92B-420D6E73CA5D}.Release|x86.ActiveCfg = Release|Any CPU {F3B898C3-D441-4207-A92B-420D6E73CA5D}.Release|x86.Build.0 = Release|Any CPU + {854133D5-6252-4A0A-B682-BDBB83B62AE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {854133D5-6252-4A0A-B682-BDBB83B62AE6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {854133D5-6252-4A0A-B682-BDBB83B62AE6}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {854133D5-6252-4A0A-B682-BDBB83B62AE6}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {854133D5-6252-4A0A-B682-BDBB83B62AE6}.Debug|x86.ActiveCfg = Debug|Any CPU + {854133D5-6252-4A0A-B682-BDBB83B62AE6}.Debug|x86.Build.0 = Debug|Any CPU + {854133D5-6252-4A0A-B682-BDBB83B62AE6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {854133D5-6252-4A0A-B682-BDBB83B62AE6}.Release|Any CPU.Build.0 = Release|Any CPU + {854133D5-6252-4A0A-B682-BDBB83B62AE6}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {854133D5-6252-4A0A-B682-BDBB83B62AE6}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {854133D5-6252-4A0A-B682-BDBB83B62AE6}.Release|x86.ActiveCfg = Release|Any CPU + {854133D5-6252-4A0A-B682-BDBB83B62AE6}.Release|x86.Build.0 = Release|Any CPU + {B4A43221-DE95-47BB-A2D4-2DC761FC9419}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4A43221-DE95-47BB-A2D4-2DC761FC9419}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4A43221-DE95-47BB-A2D4-2DC761FC9419}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {B4A43221-DE95-47BB-A2D4-2DC761FC9419}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {B4A43221-DE95-47BB-A2D4-2DC761FC9419}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4A43221-DE95-47BB-A2D4-2DC761FC9419}.Debug|x86.Build.0 = Debug|Any CPU + {B4A43221-DE95-47BB-A2D4-2DC761FC9419}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4A43221-DE95-47BB-A2D4-2DC761FC9419}.Release|Any CPU.Build.0 = Release|Any CPU + {B4A43221-DE95-47BB-A2D4-2DC761FC9419}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {B4A43221-DE95-47BB-A2D4-2DC761FC9419}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {B4A43221-DE95-47BB-A2D4-2DC761FC9419}.Release|x86.ActiveCfg = Release|Any CPU + {B4A43221-DE95-47BB-A2D4-2DC761FC9419}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -208,5 +236,7 @@ Global {0D190EE0-E305-403D-AC01-DEE71D8DBDB5} = {699DB330-0095-4266-B7B0-3EAB3710CA49} {84073E58-1802-4525-A9E5-1E6A70DAF0B2} = {699DB330-0095-4266-B7B0-3EAB3710CA49} {F3B898C3-D441-4207-A92B-420D6E73CA5D} = {09920C51-6220-4D8D-94DC-E70C13446187} + {854133D5-6252-4A0A-B682-BDBB83B62AE6} = {699DB330-0095-4266-B7B0-3EAB3710CA49} + {B4A43221-DE95-47BB-A2D4-2DC761FC9419} = {09920C51-6220-4D8D-94DC-E70C13446187} EndGlobalSection EndGlobal diff --git a/src/Microsoft.Extensions.Logging.Abstractions/Internal/NullLogger.cs b/src/Microsoft.Extensions.Logging.Abstractions/Internal/NullLogger.cs new file mode 100644 index 00000000..299e0b82 --- /dev/null +++ b/src/Microsoft.Extensions.Logging.Abstractions/Internal/NullLogger.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Logging.Abstractions.Internal +{ + public class NullLogger : ILogger + { + public static NullLogger Instance { get; } = new NullLogger(); + + private NullLogger() + { + } + + public IDisposable BeginScope(TState state) + { + return NullScope.Instance; + } + + public bool IsEnabled(LogLevel logLevel) + { + return false; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + } + } +} diff --git a/src/Microsoft.Extensions.Logging.Abstractions/Internal/NullLoggerProvider.cs b/src/Microsoft.Extensions.Logging.Abstractions/Internal/NullLoggerProvider.cs new file mode 100644 index 00000000..b933771f --- /dev/null +++ b/src/Microsoft.Extensions.Logging.Abstractions/Internal/NullLoggerProvider.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Logging.Abstractions.Internal +{ + public class NullLoggerProvider : ILoggerProvider + { + public static NullLoggerProvider Instance { get; } = new NullLoggerProvider(); + + private NullLoggerProvider() + { + } + + public ILogger CreateLogger(string categoryName) + { + return NullLogger.Instance; + } + + public void Dispose() + { + } + } +} diff --git a/src/Microsoft.Extensions.Logging.Abstractions/Internal/NullScope.cs b/src/Microsoft.Extensions.Logging.Abstractions/Internal/NullScope.cs new file mode 100644 index 00000000..3b185652 --- /dev/null +++ b/src/Microsoft.Extensions.Logging.Abstractions/Internal/NullScope.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Logging.Abstractions.Internal +{ + /// + /// An empty scope without any logic + /// + public class NullScope : IDisposable + { + public static NullScope Instance { get; } = new NullScope(); + + private NullScope() + { + } + + /// + public void Dispose() + { + } + } +} diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsFactoryExtensions.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsFactoryExtensions.cs new file mode 100644 index 00000000..d1a09a85 --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsFactoryExtensions.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.Logging.AzureWebAppDiagnostics; +using Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal; + +namespace Microsoft.Extensions.Logging +{ + /// + /// Extension methods for . + /// + public static class AzureWebAppDiagnosticsFactoryExtensions + { + /// + /// Adds an Azure Web Apps diagnostics logger. + /// + /// The extension method argument + /// A strictly positive value representing the maximum log size in megabytes. Once the log is full, no more message will be appended + public static ILoggerFactory AddAzureWebAppDiagnostics(this ILoggerFactory factory, int fileSizeLimitMb = FileLoggerProvider.DefaultFileSizeLimitMb) + { + if (WebAppContext.Default.IsRunningInAzureWebApp) + { + // Only add the provider if we're in Azure WebApp. That cannot change once the apps started + factory.AddProvider(new AzureWebAppDiagnosticsLoggerProvider(WebAppContext.Default, fileSizeLimitMb)); + } + return factory; + } + } +} diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsLoggerProvider.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsLoggerProvider.cs new file mode 100644 index 00000000..bb0e62d9 --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsLoggerProvider.cs @@ -0,0 +1,58 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.Logging.Abstractions.Internal; +using Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal; + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics +{ + /// + /// Logger provider for Azure WebApp. + /// + public class AzureWebAppDiagnosticsLoggerProvider : ILoggerProvider + { + private readonly IWebAppLogConfigurationReader _configurationReader; + + private readonly ILoggerProvider _innerLoggerProvider; + private readonly bool _runningInWebApp; + + /// + /// Creates a new instance of the class. + /// + public AzureWebAppDiagnosticsLoggerProvider(WebAppContext context, int fileSizeLimitMb) + { + _configurationReader = new WebAppLogConfigurationReader(context); + + var config = _configurationReader.Current; + _runningInWebApp = config.IsRunningInWebApp; + + if (!_runningInWebApp) + { + _innerLoggerProvider = NullLoggerProvider.Instance; + } + else + { + _innerLoggerProvider = new FileLoggerProvider(_configurationReader, fileSizeLimitMb); + + if (!string.IsNullOrEmpty(config.BlobContainerUrl)) + { + // TODO: Add the blob logger by creating a composite inner logger which calls + // both loggers + } + } + } + + /// + public ILogger CreateLogger(string categoryName) + { + return _innerLoggerProvider.CreateLogger(categoryName); + } + + /// + public void Dispose() + { + _innerLoggerProvider.Dispose(); + _configurationReader.Dispose(); + } + } +} diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/BackgroundSink.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/BackgroundSink.cs new file mode 100644 index 00000000..5fb9ae8f --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/BackgroundSink.cs @@ -0,0 +1,110 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Threading; +using Serilog.Core; +using Serilog.Events; + +// TODO: Might want to consider using https://github.com/jezzsantos/Serilog.Sinks.Async +// instead of this, once that supports netstandard +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal +{ + /// + /// A background sink for Serilog. + /// + public class BackgroundSink : ILogEventSink, IDisposable + { + /// + /// The default queue size. + /// + public const int DefaultLogMessagesQueueSize = 1024; + + private readonly CancellationTokenSource _disposedTokenSource = new CancellationTokenSource(); + private readonly CancellationToken _disposedToken; + + private readonly BlockingCollection _messages; + private readonly Thread _workerThread; + + private ILogEventSink _innerSink; + + /// + /// Creates a new instance of the class. + /// + /// The inner sink which does the actual logging + /// The maximum size of the background queue + public BackgroundSink(ILogEventSink innerSink, int? maxQueueSize) + { + if (innerSink == null) + { + throw new ArgumentNullException(nameof(innerSink)); + } + + _disposedToken = _disposedTokenSource.Token; + + if (maxQueueSize == null || maxQueueSize <= 0) + { + _messages = new BlockingCollection(new ConcurrentQueue()); + } + else + { + _messages = new BlockingCollection(new ConcurrentQueue(), maxQueueSize.Value); + } + + _innerSink = innerSink; + + _workerThread = new Thread(Worker); + _workerThread.Name = GetType().Name; + _workerThread.IsBackground = true; + _workerThread.Start(); + } + + /// + public void Emit(LogEvent logEvent) + { + if (!_disposedToken.IsCancellationRequested) + { + _messages.Add(logEvent); + } + } + + /// + /// Disposes this object instance. + /// + public virtual void Dispose() + { + lock (_disposedTokenSource) + { + if (!_disposedTokenSource.IsCancellationRequested) + { + _disposedTokenSource.Cancel(); + } + + // Wait for the thread to complete before disposing the resources + _workerThread.Join(5 /*seconds */ * 1000); + _messages.Dispose(); + } + } + + private void Worker() + { + try + { + foreach (var logEvent in _messages.GetConsumingEnumerable(_disposedToken)) + { + PassLogEventToInnerSink(logEvent); + } + } + catch (OperationCanceledException) + { + // Do nothing, we just cancelled the task + } + } + + private void PassLogEventToInnerSink(LogEvent logEvent) + { + _innerSink.Emit(logEvent); + } + } +} diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/FileLoggerProvider.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/FileLoggerProvider.cs new file mode 100644 index 00000000..f1a12ff6 --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/FileLoggerProvider.cs @@ -0,0 +1,72 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using Serilog; +using Serilog.Formatting.Display; +using Serilog.Sinks.RollingFile; + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal +{ + /// + /// A file logger for Azure WebApp. + /// + public class FileLoggerProvider : SerilogLoggerProvider + { + /// + /// The default file size limit in megabytes + /// + public const int DefaultFileSizeLimitMb = 10; + + // Two days retention limit is okay because the file logger turns itself off after 12 hours (portal feature) + private const int RetainedFileCountLimit = 2; // Days (also number of files because we have 1 file/day) + + private const string OutputTemplate = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level}] {Message}{NewLine}{Exception}"; + private const string FileNamePattern = "diagnostics-{Date}.txt"; + + /// + /// Creates a new instance of the class. + /// + /// A configuration reader + /// A strictly positive value representing the maximum log size in megabytes. Once the log is full, no more message will be appended + public FileLoggerProvider(IWebAppLogConfigurationReader configReader, int fileSizeLimit) + : base(configReader, (loggerConfiguration, webAppConfiguration) => + { + if (string.IsNullOrEmpty(webAppConfiguration.FileLoggingFolder)) + { + throw new ArgumentNullException(nameof(webAppConfiguration.FileLoggingFolder), "The file logger path cannot be null or empty."); + } + + var logsFolder = webAppConfiguration.FileLoggingFolder; + if (!Directory.Exists(logsFolder)) + { + Directory.CreateDirectory(logsFolder); + } + var logsFilePattern = Path.Combine(logsFolder, FileNamePattern); + + var fileSizeLimitBytes = fileSizeLimit * 1024 * 1024; + + var messageFormatter = new MessageTemplateTextFormatter(OutputTemplate, null); + var rollingFileSink = new RollingFileSink(logsFilePattern, messageFormatter, fileSizeLimitBytes, RetainedFileCountLimit); + var backgroundSink = new BackgroundSink(rollingFileSink, BackgroundSink.DefaultLogMessagesQueueSize); + + loggerConfiguration.WriteTo.Sink(backgroundSink); + }) + { + } + + /// + protected override void OnConfigurationChanged(WebAppLogConfiguration newConfiguration) + { + if (!newConfiguration.FileLoggingEnabled) + { + LevelSwitcher.MinimumLevel = LogLevelDisabled; + } + else + { + LevelSwitcher.MinimumLevel = LogLevelToLogEventLevel(newConfiguration.FileLoggingLevel); + } + } + } +} diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/IWebAppContext.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/IWebAppContext.cs new file mode 100644 index 00000000..7af58cf3 --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/IWebAppContext.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal +{ + /// + /// Represents an Azure WebApp context + /// + public interface IWebAppContext + { + /// + /// Gets the path to the home folder if running in Azure WebApp + /// + string HomeFolder { get; } + + /// + /// Gets a value indicating whether or new we're in an Azure WebApp + /// + bool IsRunningInAzureWebApp { get; } + } +} diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/IWebAppLogConfigurationReader.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/IWebAppLogConfigurationReader.cs new file mode 100644 index 00000000..a09a4cea --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/IWebAppLogConfigurationReader.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal +{ + + /// + /// The contract for a WebApp configuration reader. + /// + public interface IWebAppLogConfigurationReader : IDisposable + { + /// + /// Triggers when the configuration has changed. + /// + event EventHandler OnConfigurationChanged; + + /// + /// The current value of the configuration. + /// + WebAppLogConfiguration Current { get; } + } +} diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/SerilogLoggerProvider.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/SerilogLoggerProvider.cs new file mode 100644 index 00000000..6316742b --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/SerilogLoggerProvider.cs @@ -0,0 +1,125 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Serilog; +using Serilog.Core; +using Serilog.Events; + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal +{ + /// + /// Represents a Serilog logger provider use for Azure WebApp. + /// + public abstract class SerilogLoggerProvider : ILoggerProvider + { + // Solution suggested by the Serilog creator http://stackoverflow.com/questions/30849166/how-to-turn-off-serilog + /// + /// The log level at which the logger is disabled. + /// + protected static LogEventLevel LogLevelDisabled = ((LogEventLevel)1 + (int)LogEventLevel.Fatal); + + private readonly LoggingLevelSwitch _levelSwitch = new LoggingLevelSwitch(); + + private readonly IWebAppLogConfigurationReader _configReader; + private readonly ILoggerFactory _loggerFactory; + + /// + /// Creates a new instance of the class. + /// + /// The configuration reader + /// The actions required to configure the logger + public SerilogLoggerProvider(IWebAppLogConfigurationReader configReader, Action configureLogger) + { + if (configReader == null) + { + throw new ArgumentNullException(nameof(configReader)); + } + if (configureLogger == null) + { + throw new ArgumentNullException(nameof(configureLogger)); + } + + _configReader = configReader; + var webAppsConfiguration = configReader.Current; + + configReader.OnConfigurationChanged += OnConfigurationChanged; + + var loggerConfiguration = new LoggerConfiguration() + .MinimumLevel.ControlledBy(_levelSwitch); + configureLogger(loggerConfiguration, webAppsConfiguration); + var serilogLogger = loggerConfiguration.CreateLogger(); + + OnConfigurationChanged(webAppsConfiguration); + + _loggerFactory = new LoggerFactory(); + _loggerFactory.AddSerilog(serilogLogger); + } + + /// + /// The switch used the modify the logging level. + /// + protected LoggingLevelSwitch LevelSwitcher => _levelSwitch; + + /// + /// Called when the configuration changes + /// + /// The new configuration values + protected abstract void OnConfigurationChanged(WebAppLogConfiguration newConfiguration); + + /// + public ILogger CreateLogger(string categoryName) + { + return _loggerFactory.CreateLogger(categoryName); + } + + /// + /// Disposes this object. + /// + public void Dispose() + { + _configReader.OnConfigurationChanged -= OnConfigurationChanged; + _loggerFactory.Dispose(); + } + + private void OnConfigurationChanged(object sender, WebAppLogConfiguration newConfiguration) + { + OnConfigurationChanged(newConfiguration); + } + + /// + /// Converts a object to . + /// + /// The log level to convert + /// A instance + protected static LogEventLevel LogLevelToLogEventLevel(LogLevel logLevel) + { + switch (logLevel) + { + case LogLevel.Trace: + return LogEventLevel.Verbose; + + case LogLevel.Debug: + return LogEventLevel.Debug; + + case LogLevel.Information: + return LogEventLevel.Information; + + case LogLevel.Warning: + return LogEventLevel.Warning; + + case LogLevel.Error: + return LogEventLevel.Error; + + case LogLevel.Critical: + return LogEventLevel.Fatal; + + case LogLevel.None: + return LogLevelDisabled; + + default: + throw new ArgumentOutOfRangeException($"Unknown log level: {logLevel}"); + } + } + } +} diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppContext.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppContext.cs new file mode 100644 index 00000000..4d473dbf --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppContext.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal +{ + /// + /// Represents the default implementation of . + /// + public class WebAppContext : IWebAppContext + { + /// + /// Gets the default instance of the WebApp context. + /// + public static WebAppContext Default { get; } = new WebAppContext(); + + private WebAppContext() { } + + /// + public string HomeFolder { get; } = Environment.GetEnvironmentVariable("HOME"); + + /// + public bool IsRunningInAzureWebApp + { + get + { + return + !string.IsNullOrEmpty(HomeFolder) && + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME")); + } + } + } +} diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfiguration.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfiguration.cs new file mode 100644 index 00000000..12b7545b --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfiguration.cs @@ -0,0 +1,67 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal +{ + /// + /// Represents the configuration of the logger from Azure WebApp. + /// + public class WebAppLogConfiguration + { + /// + /// The configuration that has all loggers disabled. + /// + public static WebAppLogConfiguration Disabled { get; } = new WebAppLogConfigurationBuilder().Build(); + + internal WebAppLogConfiguration( + bool isRunningInWebApp, + bool fileLoggingEnabled, + LogLevel fileLoggingLevel, + string fileLoggingFolder, + bool blobLoggingEnabled, + LogLevel blobLoggingLevel, + string blobContainerUrl) + { + IsRunningInWebApp = isRunningInWebApp; + + FileLoggingEnabled = fileLoggingEnabled; + FileLoggingLevel = fileLoggingLevel; + FileLoggingFolder = fileLoggingFolder; + + BlobLoggingEnabled = blobLoggingEnabled; + BlobLoggingLevel = blobLoggingLevel; + BlobContainerUrl = blobContainerUrl; + } + + /// + /// Gets a value indicating whether we're running in WebApp or not. + /// + public bool IsRunningInWebApp { get; private set; } + + /// + /// Gets a value indicating whether the file logger is enabled or not. + /// + public bool FileLoggingEnabled { get; private set; } + /// + /// Gets a value indicating the file logger logging level. + /// + public LogLevel FileLoggingLevel { get; private set; } + /// + /// Gets a value indicating the folder where the file logger stores the logs. + /// + public string FileLoggingFolder { get; private set; } + + /// + /// Gets a value indicating whether the blob logger is enabled or not. + /// + public bool BlobLoggingEnabled { get; private set; } + /// + /// Gets a value indicating the blob logger logging level. + /// + public LogLevel BlobLoggingLevel { get; private set; } + /// + /// Gets the SAS endpoint where blob logs are stored. + /// + public string BlobContainerUrl { get; private set; } + } +} diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfigurationBuilder.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfigurationBuilder.cs new file mode 100644 index 00000000..776a808e --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfigurationBuilder.cs @@ -0,0 +1,114 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal +{ + /// + /// Used to create instances of + /// + public class WebAppLogConfigurationBuilder + { + private bool _isRunningInAzureWebApps; + + private bool _fileLoggingEnabled; + private LogLevel _fileLoggingLevel = LogLevel.None; + private string _fileLoggingFolder; + + private bool _blobLoggingEnabled; + private LogLevel _blobLoggingLevel = LogLevel.None; + private string _blobContainerUrl; + + /// + /// Sets a value indicating whether or not we're in an Azure context + /// + /// True if running in Azure, false otherwise + /// The builder instance + public WebAppLogConfigurationBuilder SetIsRunningInAzureWebApps(bool isRunningInAzureWebApps) + { + _isRunningInAzureWebApps = isRunningInAzureWebApps; + return this; + } + + /// + /// Sets a value indicating whether or not file logging is enabled + /// + /// True if file logging is enabled, false otherwise + /// The builder instance + public WebAppLogConfigurationBuilder SetFileLoggingEnabled(bool fileLoggingEnabled) + { + _fileLoggingEnabled = fileLoggingEnabled; + return this; + } + + /// + /// Sets logging level for the file logger + /// + /// File logging level + /// The builder instance + public WebAppLogConfigurationBuilder SetFileLoggingLevel(LogLevel logLevel) + { + _fileLoggingLevel = logLevel; + return this; + } + + /// + /// Sets the folder in which file logs end up + /// + /// File logging folder + /// The builder instance + public WebAppLogConfigurationBuilder SetFileLoggingFolder(string folder) + { + _fileLoggingFolder = folder; + return this; + } + + /// + /// Sets a value indicating whether or not blob logging is enabled + /// + /// True if file logging is enabled, false otherwise + /// The builder instance + public WebAppLogConfigurationBuilder SetBlobLoggingEnabled(bool blobLoggingEnabled) + { + _blobLoggingEnabled = blobLoggingEnabled; + return this; + } + + /// + /// Sets logging level for the blob logger + /// + /// Blob logging level + /// The builder instance + public WebAppLogConfigurationBuilder SetBlobLoggingLevel(LogLevel logLevel) + { + _blobLoggingLevel = logLevel; + return this; + } + + /// + /// Sets blob logging url + /// + /// The container in which blobs are placed + /// The builder instance + public WebAppLogConfigurationBuilder SetBlobLoggingUrl(string blobUrl) + { + _blobContainerUrl = blobUrl; + return this; + } + + /// + /// Builds the instance + /// + /// The configuration object + public WebAppLogConfiguration Build() + { + return new WebAppLogConfiguration( + isRunningInWebApp: _isRunningInAzureWebApps, + fileLoggingEnabled: _fileLoggingEnabled, + fileLoggingLevel: _fileLoggingLevel, + fileLoggingFolder: _fileLoggingFolder, + blobLoggingEnabled: _blobLoggingEnabled, + blobLoggingLevel: _blobLoggingLevel, + blobContainerUrl: _blobContainerUrl); + } + } +} diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfigurationReader.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfigurationReader.cs new file mode 100644 index 00000000..db519f9e --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfigurationReader.cs @@ -0,0 +1,149 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal +{ + /// + /// Represents the default implementation of the . + /// + public class WebAppLogConfigurationReader : IWebAppLogConfigurationReader + { + private readonly IConfigurationRoot _configuration; + private readonly string _fileLogFolder; + + private WebAppLogConfiguration _latestConfiguration; + private IDisposable _changeSubscription; + + /// + public event EventHandler OnConfigurationChanged; + + /// + /// Creates a new instance of the class. + /// + /// The context in which the reader runs + public WebAppLogConfigurationReader(IWebAppContext context) + { + if (!context.IsRunningInAzureWebApp) + { + _latestConfiguration = WebAppLogConfiguration.Disabled; + } + else + { + _fileLogFolder = Path.Combine(context.HomeFolder, "LogFiles", "Application"); + var settingsFolder = Path.Combine(context.HomeFolder, "site", "diagnostics"); + var settingsFile = Path.Combine(settingsFolder, "settings.json"); + + // TODO: This is a workaround because the file provider doesn't handle missing folders/files + if (!Directory.Exists(settingsFolder)) + { + Directory.CreateDirectory(settingsFolder); + } + if (!File.Exists(settingsFile)) + { + File.WriteAllText(settingsFile, "{}"); + } + + _configuration = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddJsonFile(settingsFile, optional: true, reloadOnChange: true) + .Build(); + + SubscribeToConfigurationChangeEvent(); + ReloadConfiguration(); + } + } + + /// + public WebAppLogConfiguration Current + { + get + { + return _latestConfiguration; + } + } + + /// + /// Disposes the object instance. + /// + public void Dispose() + { + DisposeChangeSubscription(); + } + + private void OnConfigurationTokenChange(object state) + { + ReloadConfiguration(); + SubscribeToConfigurationChangeEvent(); + + if (OnConfigurationChanged != null) + { + OnConfigurationChanged(this, _latestConfiguration); + } + } + + private void SubscribeToConfigurationChangeEvent() + { + DisposeChangeSubscription(); + + // The token from configuration has to be renewed after each trigger + var changeToken = _configuration.GetReloadToken(); + _changeSubscription = changeToken.RegisterChangeCallback(OnConfigurationTokenChange, null); + } + + private void ReloadConfiguration() + { + // Don't use the binder because of all the defaults that we want in place + _latestConfiguration = new WebAppLogConfigurationBuilder() + .SetIsRunningInAzureWebApps(true) + .SetFileLoggingEnabled(TextToBoolean(_configuration.GetSection("AzureDriveEnabled")?.Value)) + .SetFileLoggingLevel(TextToLogLevel(_configuration.GetSection("AzureDriveTraceLevel")?.Value)) + .SetFileLoggingFolder(_fileLogFolder) + .SetBlobLoggingEnabled(TextToBoolean(_configuration.GetSection("AzureBlobEnabled")?.Value)) + .SetBlobLoggingLevel(TextToLogLevel(_configuration.GetSection("AzureBlobTraceLevel")?.Value)) + .SetBlobLoggingUrl(_configuration.GetSection("APPSETTING_DIAGNOSTICS_AZUREBLOBCONTAINERSASURL")?.Value) + .Build(); + } + + private void DisposeChangeSubscription() + { + if (_changeSubscription != null) + { + _changeSubscription.Dispose(); + _changeSubscription = null; + } + } + + private static bool TextToBoolean(string text) + { + bool result; + if (string.IsNullOrEmpty(text) || + !bool.TryParse(text, out result)) + { + result = false; + } + + return result; + } + + private static LogLevel TextToLogLevel(string text) + { + switch (text?.ToUpperInvariant()) + { + case "ERROR": + return LogLevel.Error; + case "WARNING": + return LogLevel.Warning; + case "INFORMATION": + return LogLevel.Information; + case "VERBOSE": + return LogLevel.Trace; + default: + return LogLevel.None; + } + } + } +} diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.xproj b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.xproj new file mode 100644 index 00000000..51559b88 --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.xproj @@ -0,0 +1,17 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + 854133d5-6252-4a0a-b682-bdbb83b62ae6 + .\obj + .\bin\ + + + 2.0 + + + \ No newline at end of file diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Properties/AssemblyInfo.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..8d8d8819 --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Properties/AssemblyInfo.cs @@ -0,0 +1,11 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Reflection; +using System.Resources; + +[assembly: AssemblyMetadata("Serviceable", "True")] +[assembly: NeutralResourcesLanguage("en-us")] +[assembly: AssemblyCompany("Microsoft Corporation.")] +[assembly: AssemblyCopyright("© Microsoft Corporation. All rights reserved.")] +[assembly: AssemblyProduct("Microsoft .NET Extensions")] diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/project.json b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/project.json new file mode 100644 index 00000000..1e0aee54 --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/project.json @@ -0,0 +1,37 @@ +{ + "version": "1.1.0-*", + "description": "", + "packOptions": { + "tags": [ + "logging" + ] + }, + "buildOptions": { + "warningsAsErrors": true, + "keyFile": "../../tools/Key.snk", + "xmlDoc": true + }, + "dependencies": { + "Microsoft.Extensions.Configuration.EnvironmentVariables": "1.1.0-*", + "Microsoft.Extensions.Configuration.Json": "1.1.0-*", + "Microsoft.Extensions.Logging": "1.1.0-*", + "Microsoft.Extensions.Logging.Abstractions": "1.1.0-*", + "Serilog.Extensions.Logging": "1.0.0", + "Serilog.Sinks.RollingFile": "2.1.0" + }, + "frameworks": { + "net451": { + "frameworkAssemblies": { + "System.Runtime": { + "type": "build" + } + } + }, + "netstandard1.3": { + "dependencies": { + "System.Console": "4.0.0-*", + "System.Threading.Thread": "4.0.0-*" + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Extensions.Logging/Logger.cs b/src/Microsoft.Extensions.Logging/Logger.cs index 3bdd221f..61fce72b 100644 --- a/src/Microsoft.Extensions.Logging/Logger.cs +++ b/src/Microsoft.Extensions.Logging/Logger.cs @@ -3,13 +3,12 @@ using System; using System.Collections.Generic; +using Microsoft.Extensions.Logging.Abstractions.Internal; namespace Microsoft.Extensions.Logging { internal class Logger : ILogger { - private static readonly NullScope _nullScope = new NullScope(); - private readonly LoggerFactory _loggerFactory; private readonly string _name; private ILogger[] _loggers; @@ -104,7 +103,7 @@ public IDisposable BeginScope(TState state) { if (_loggers == null) { - return _nullScope; + return NullScope.Instance; } if (_loggers.Length == 1) @@ -225,12 +224,5 @@ internal void Add(IDisposable disposable) throw new NotImplementedException(); } } - - private class NullScope : IDisposable - { - public void Dispose() - { - } - } } } \ No newline at end of file diff --git a/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/BackgroundSinkTests.cs b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/BackgroundSinkTests.cs new file mode 100644 index 00000000..86c4275d --- /dev/null +++ b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/BackgroundSinkTests.cs @@ -0,0 +1,115 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal; +using Serilog; +using Xunit; + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test +{ + public class BackgroundSinkTests + { + private readonly int DefaultTimeout = (int)TimeSpan.FromSeconds(10).TotalMilliseconds; + + [Fact] + public void MessagesOrderIsMaintained() + { + var testSink = new TestSink(); + + using (var allLogged = new ManualResetEvent(false)) + using (var backgroundSink = new BackgroundSink(testSink, BackgroundSink.DefaultLogMessagesQueueSize)) + { + testSink.Events.CollectionChanged += (sender, e) => + { + if (testSink.Events.Count >= 3) + { + allLogged.Set(); + } + }; + + var logger = new LoggerConfiguration() + .WriteTo.Sink(backgroundSink) + .CreateLogger(); + + logger.Information("5"); + logger.Information("1"); + logger.Information("3"); + + Assert.True(allLogged.WaitOne(DefaultTimeout)); + + var eventsText = testSink.Events.Select(e => e.MessageTemplate.Text).ToArray(); + + Assert.Equal("5", eventsText[0]); + Assert.Equal("1", eventsText[1]); + Assert.Equal("3", eventsText[2]); + } + } + + [Fact] + public void BlocksWhenQueueIsFull() + { + using (var unblockEvent = new ManualResetEvent(false)) + { + var testSink = new TestSink + { + // Block inner logging write (simulates slow writes) + // When combined with a limited size queue, it will + // be like having more logs than it can process + Filter = ev => + { + unblockEvent.WaitOne(DefaultTimeout); + } + }; + + using (var allLogged = new ManualResetEvent(false)) + using (var backgroundSink = new BackgroundSink(testSink, maxQueueSize: 1)) + { + testSink.Events.CollectionChanged += (sender, e) => + { + if (testSink.Events.Count >= 3) + { + allLogged.Set(); + } + }; + + var logger = new LoggerConfiguration() + .WriteTo.Sink(backgroundSink) + .CreateLogger(); + + logger.Information("7"); + logger.Information("3"); + + var secondLogTask = Task.Run(() => + { + logger.Information("1"); + }); + + // There should be no events written while the queue is blocked + // and no more logs should be added + var logWasUnblocked = secondLogTask.Wait(DefaultTimeout / 10); + var sinkHasEvents = testSink.Events.Any(); + + // Now unblock and wait for all events to flush + unblockEvent.Set(); + + // Postpone the assert until after we unblock the event + // otherwise xunit will hang because it blocks the test thread + Assert.False(logWasUnblocked); + Assert.False(sinkHasEvents); + + Assert.True(allLogged.WaitOne(DefaultTimeout)); + + var eventsText = testSink.Events.Select(e => e.MessageTemplate.Text).ToArray(); + + Assert.Equal("7", eventsText[0]); + Assert.Equal("3", eventsText[1]); + Assert.Equal("1", eventsText[2]); + } + } + } + } +} diff --git a/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/LogConfigurationReaderTests.cs b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/LogConfigurationReaderTests.cs new file mode 100644 index 00000000..5baa94a3 --- /dev/null +++ b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/LogConfigurationReaderTests.cs @@ -0,0 +1,208 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Threading; +using Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal; +using Moq; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test +{ + public class LogConfigurationReaderTests + { + private readonly int DefaultTimeout = (int)TimeSpan.FromSeconds(10).TotalMilliseconds; + + [Fact] + public void OutsideOfWebAppTheConfigurationIsDisabled() + { + var contextMock = new Mock(MockBehavior.Strict); + contextMock.SetupGet(c => c.IsRunningInAzureWebApp).Returns(false); + + var configReader = new WebAppLogConfigurationReader(contextMock.Object); + + Assert.Same(WebAppLogConfiguration.Disabled, configReader.Current); + } + + [Fact] + public void NoConfigFile() + { + var tempFolder = Path.Combine(Path.GetTempPath(), "AzureWebAppLoggerThisFolderShouldNotExist"); + var logFolder = Path.Combine(tempFolder, "LogFiles", "Application"); + + var contextMock = new Mock(); + contextMock.SetupGet(c => c.IsRunningInAzureWebApp) + .Returns(true); + + contextMock.SetupGet(c => c.HomeFolder) + .Returns(tempFolder); + + using (var configReader = new WebAppLogConfigurationReader(contextMock.Object)) + { + var config = configReader.Current; + + Assert.True(config.IsRunningInWebApp); + + Assert.False(config.FileLoggingEnabled); + Assert.Equal(LogLevel.None, config.FileLoggingLevel); + Assert.Equal(logFolder, config.FileLoggingFolder); + + Assert.False(config.BlobLoggingEnabled); + Assert.Equal(LogLevel.None, config.BlobLoggingLevel); + Assert.Null(config.BlobContainerUrl); + } + } + + [Fact] + public void ConfigurationDisabledInSettingsFile() + { + var tempFolder = Path.Combine(Path.GetTempPath(), "WebAppLoggerConfigurationDisabledInSettingsFile"); + + try + { + var logFolder = Path.Combine(tempFolder, "LogFiles", "Application"); + + var settingsFolder = Path.Combine(tempFolder, "site", "diagnostics"); + var settingsFile = Path.Combine(settingsFolder, "settings.json"); + + if (!Directory.Exists(settingsFolder)) + { + Directory.CreateDirectory(settingsFolder); + } + + var settingsFileContent = new SettingsFileContent + { + AzureDriveEnabled = false, + AzureDriveTraceLevel = "Verbose", + + AzureBlobEnabled = false, + AzureBlobTraceLevel = "Error" + }; + + File.WriteAllText(settingsFile, JsonConvert.SerializeObject(settingsFileContent)); + + var contextMock = new Mock(); + contextMock.SetupGet(c => c.IsRunningInAzureWebApp) + .Returns(true); + contextMock.SetupGet(c => c.HomeFolder) + .Returns(tempFolder); + + using (var configReader = new WebAppLogConfigurationReader(contextMock.Object)) + { + var config = configReader.Current; + + Assert.False(config.FileLoggingEnabled); + Assert.Equal(LogLevel.Trace, config.FileLoggingLevel); + + Assert.False(config.BlobLoggingEnabled); + Assert.Equal(LogLevel.Error, config.BlobLoggingLevel); + } + } + finally + { + if (Directory.Exists(tempFolder)) + { + try + { + Directory.Delete(tempFolder, recursive: true); + } + catch + { + // Don't break the test if temp folder deletion fails. + } + } + } + } + + [Fact] + public void ConfigurationChange() + { + var tempFolder = Path.Combine(Path.GetTempPath(), "WebAppLoggerConfigurationChange"); + + try + { + var logFolder = Path.Combine(tempFolder, "LogFiles", "Application"); + + var settingsFolder = Path.Combine(tempFolder, "site", "diagnostics"); + var settingsFile = Path.Combine(settingsFolder, "settings.json"); + + if (!Directory.Exists(settingsFolder)) + { + Directory.CreateDirectory(settingsFolder); + } + + var settingsFileContent = new SettingsFileContent + { + AzureDriveEnabled = false, + AzureDriveTraceLevel = "Verbose", + + AzureBlobEnabled = false, + AzureBlobTraceLevel = "Error" + }; + + File.WriteAllText(settingsFile, JsonConvert.SerializeObject(settingsFileContent)); + + var contextMock = new Mock(); + contextMock.SetupGet(c => c.IsRunningInAzureWebApp) + .Returns(true); + contextMock.SetupGet(c => c.HomeFolder) + .Returns(tempFolder); + + using (var configChangedEvent = new ManualResetEvent(false)) + using (var configReader = new WebAppLogConfigurationReader(contextMock.Object)) + { + WebAppLogConfiguration config = null; + + configReader.OnConfigurationChanged += (sender, newConfig) => + { + config = newConfig; + + try + { + configChangedEvent.Set(); + } + catch(ObjectDisposedException) + { + // This can happen if the file watcher triggers multiple times + // and there are in flight events that run after we dispose + // the manual reset event. Same issue as in dotnet-watch + } + }; + + // Wait 1 second because on unix the file time resolution is 1s and the watcher might not fire + Thread.Sleep(TimeSpan.FromSeconds(1)); + settingsFileContent.AzureBlobEnabled = true; + settingsFileContent.AzureDriveTraceLevel = "Information"; + File.WriteAllText(settingsFile, JsonConvert.SerializeObject(settingsFileContent)); + + var configChanged = configChangedEvent.WaitOne(DefaultTimeout); + + Assert.True(configChanged); + Assert.Same(config, configReader.Current); + + Assert.False(config.FileLoggingEnabled); + Assert.Equal(LogLevel.Information, config.FileLoggingLevel); + + Assert.True(config.BlobLoggingEnabled); + Assert.Equal(LogLevel.Error, config.BlobLoggingLevel); + } + } + finally + { + if (Directory.Exists(tempFolder)) + { + try + { + Directory.Delete(tempFolder, recursive: true); + } + catch + { + // Don't break the test if temp folder deletion fails. + } + } + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test.xproj b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test.xproj new file mode 100644 index 00000000..7af8128f --- /dev/null +++ b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test.xproj @@ -0,0 +1,21 @@ + + + + 14.0.25420 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + b4a43221-de95-47bb-a2d4-2dc761fc9419 + Microsoft.Extensions.Logging.AzureWebApps.Test + .\obj + .\bin\ + + + 2.0 + + + + + + \ No newline at end of file diff --git a/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/SerilogLoggerProviderTests.cs b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/SerilogLoggerProviderTests.cs new file mode 100644 index 00000000..e7b2bf10 --- /dev/null +++ b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/SerilogLoggerProviderTests.cs @@ -0,0 +1,208 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal; +using Moq; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using Xunit; + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test +{ + public class SerilogLoggerProviderTests + { + [Fact] + public void OnStartDisable() + { + var configReader = new Mock(); + configReader.SetupGet(m => m.Current).Returns(WebAppLogConfiguration.Disabled); + + // Nothing should be called on this object + var testSink = new Mock(MockBehavior.Strict); + + using (var provider = new TestWebAppSerilogLoggerProvider(testSink.Object, configReader.Object)) + { + var logger = provider.CreateLogger("TestLogger"); + logger.LogInformation("Test"); + } + } + + [Fact] + public void OnStartLoggingLevel() + { + var config = new WebAppLogConfigurationBuilder() + .SetIsRunningInAzureWebApps(true) + .SetFileLoggingEnabled(true) + .SetFileLoggingLevel(LogLevel.Information) + .Build(); + + var configReader = new Mock(); + configReader.SetupGet(m => m.Current).Returns(config); + + var testSink = new Mock(MockBehavior.Strict); + testSink.Setup(m => m.Emit(It.IsAny())); + + using (var provider = new TestWebAppSerilogLoggerProvider(testSink.Object, configReader.Object)) + { + var logger = provider.CreateLogger("TestLogger"); + logger.LogInformation("Test"); + } + + testSink.Verify(m => m.Emit(It.IsAny()), Times.Once); + } + + [Fact] + public void DynamicDisable() + { + var configBuilder = new WebAppLogConfigurationBuilder() + .SetIsRunningInAzureWebApps(true) + .SetFileLoggingEnabled(true) + .SetFileLoggingLevel(LogLevel.Information); + + var currentConfig = configBuilder.Build(); + + var configReader = new Mock(); + configReader.SetupGet(m => m.Current) + .Returns(() => { return currentConfig; }); + + var testSink = new Mock(MockBehavior.Strict); + testSink.Setup(m => m.Emit(It.IsAny())); + + using (var provider = new TestWebAppSerilogLoggerProvider(testSink.Object, configReader.Object)) + { + var logger = provider.CreateLogger("TestLogger"); + + logger.LogInformation("Test1"); + testSink.Verify(m => m.Emit(It.IsAny()), Times.Once); + + configBuilder.SetFileLoggingEnabled(false); + currentConfig = configBuilder.Build(); + + configReader.Raise(m => m.OnConfigurationChanged += (sender, e) => { }, null, currentConfig); + + // Logging should be disabled now + logger.LogInformation("Test1"); + testSink.Verify(m => m.Emit(It.IsAny()), Times.Once); + } + } + + [Fact] + public void DynamicLoggingLevel() + { + var configBuilder = new WebAppLogConfigurationBuilder() + .SetIsRunningInAzureWebApps(true) + .SetFileLoggingEnabled(true) + .SetFileLoggingLevel(LogLevel.Critical); + + var currentConfig = configBuilder.Build(); + + var configReader = new Mock(); + configReader.SetupGet(m => m.Current) + .Returns(() => { return currentConfig; }); + + var testSink = new Mock(MockBehavior.Strict); + testSink.Setup(m => m.Emit(It.IsAny())); + + using (var provider = new TestWebAppSerilogLoggerProvider(testSink.Object, configReader.Object)) + { + var logger = provider.CreateLogger("TestLogger"); + + logger.LogDebug("Test1"); + testSink.Verify(m => m.Emit(It.IsAny()), Times.Never); + + configBuilder.SetFileLoggingLevel(LogLevel.Debug); + currentConfig = configBuilder.Build(); + + configReader.Raise(m => m.OnConfigurationChanged += (sender, e) => { }, null, currentConfig); + + // Logging for this level should be enabled now + logger.LogDebug("Test1"); + testSink.Verify(m => m.Emit(It.IsAny()), Times.Once); + } + } + + // Checks that the .net log level to serilog level mappings are doing what we expect + [Fact] + public void LevelMapping() + { + var configBuilder = new WebAppLogConfigurationBuilder() + .SetIsRunningInAzureWebApps(true) + .SetFileLoggingEnabled(true); + + var currentConfig = configBuilder.Build(); + + var configReader = new Mock(); + configReader.SetupGet(m => m.Current) + .Returns(() => { return currentConfig; }); + + var testSink = new Mock(MockBehavior.Strict); + testSink.Setup(m => m.Emit(It.IsAny())); + + using (var provider = new TestWebAppSerilogLoggerProvider(testSink.Object, configReader.Object)) + { + var levelsToCheck = new LogLevel[] { + LogLevel.None, + LogLevel.Critical, + LogLevel.Error, + LogLevel.Warning, + LogLevel.Information, + LogLevel.Debug, + LogLevel.Trace + }; + + var logger = provider.CreateLogger("TestLogger"); + + for (int i = 0; i < levelsToCheck.Length; i++) + { + var enabledLevel = levelsToCheck[i]; + + // Change the logging level + configBuilder.SetFileLoggingLevel(enabledLevel); + currentConfig = configBuilder.Build(); + configReader.Raise(m => m.OnConfigurationChanged += (sender, e) => { }, null, currentConfig); + + // Don't try to log "None" (start at 1) + for (int j = 1; j < levelsToCheck.Length; j++) + { + logger.Log(levelsToCheck[j], 1, new object(), null, (state, ex) => string.Empty); + } + + // On each level we expect an extra message from the previous + testSink.Verify( + m => m.Emit(It.IsAny()), + Times.Exactly(i), + $"Enabled level: {enabledLevel}"); + + testSink.ResetCalls(); + } + } + } + + + private class TestWebAppSerilogLoggerProvider : SerilogLoggerProvider + { + public TestWebAppSerilogLoggerProvider(ILogEventSink sink, IWebAppLogConfigurationReader configReader) : + base(configReader, + (logger, config) => + { + logger.WriteTo.Sink(sink); + }) + { + } + + protected override void OnConfigurationChanged(WebAppLogConfiguration newConfiguration) + { + if (!newConfiguration.FileLoggingEnabled) + { + LevelSwitcher.MinimumLevel = LogLevelDisabled; + } + else + { + LevelSwitcher.MinimumLevel = LogLevelToLogEventLevel(newConfiguration.FileLoggingLevel); + } + } + } + } +} diff --git a/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/SettingsFileContent.cs b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/SettingsFileContent.cs new file mode 100644 index 00000000..b9782866 --- /dev/null +++ b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/SettingsFileContent.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test +{ + // The format of this file is defined by the Azure Portal. Do not change + internal class SettingsFileContent + { + public bool AzureDriveEnabled { get; set; } + public string AzureDriveTraceLevel { get; set; } + + public bool AzureTableEnabled { get; set; } + public string AzureTableTraceLevel { get; set; } + + public bool AzureBlobEnabled { get; set; } + public string AzureBlobTraceLevel { get; set; } + } +} diff --git a/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/TestSink.cs b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/TestSink.cs new file mode 100644 index 00000000..38dab91f --- /dev/null +++ b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/TestSink.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.ObjectModel; +using Serilog.Core; +using Serilog.Events; + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test +{ + internal class TestSink : ILogEventSink + { + private readonly ObservableCollection _events = new ObservableCollection(); + + public ObservableCollection Events => _events; + + public Action Filter { get; set; } + + public void Emit(LogEvent logEvent) + { + Filter?.Invoke(logEvent); + _events.Add(logEvent); + } + } +} diff --git a/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/project.json b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/project.json new file mode 100644 index 00000000..53a411a5 --- /dev/null +++ b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/project.json @@ -0,0 +1,30 @@ +{ + "buildOptions": { + "warningsAsErrors": true + }, + "dependencies": { + "dotnet-test-xunit": "2.2.0-*", + "Microsoft.Extensions.Logging.AzureWebAppDiagnostics": "1.1.0-*", + "Microsoft.Extensions.Logging.TraceSource": "1.1.0-*", + "Moq": "4.6.25-*", + "xunit": "2.2.0-*" + }, + "testRunner": "xunit", + "frameworks": { + "netcoreapp1.0": { + "imports": [ + "dotnet5.4" + ], + "dependencies": { + "Microsoft.NETCore.App": { + "version": "1.0.0-*", + "type": "platform" + } + } + }, + "net451": { + "dependencies": { + } + } + } +} \ No newline at end of file