diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsFactoryExtensions.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsFactoryExtensions.cs index d1a09a85..0124877d 100644 --- a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsFactoryExtensions.cs +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsFactoryExtensions.cs @@ -15,13 +15,22 @@ 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) + public static ILoggerFactory AddAzureWebAppDiagnostics(this ILoggerFactory factory) + { + return AddAzureWebAppDiagnostics(factory, new AzureWebAppDiagnosticsSettings()); + } + + /// + /// Adds an Azure Web Apps diagnostics logger. + /// + /// The extension method argument + /// The setting object to configure loggers. + public static ILoggerFactory AddAzureWebAppDiagnostics(this ILoggerFactory factory, AzureWebAppDiagnosticsSettings settings) { 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)); + factory.AddProvider(new AzureWebAppDiagnosticsLoggerProvider(WebAppContext.Default, settings)); } return factory; } diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsLoggerProvider.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsLoggerProvider.cs index 8029af32..4ee51c27 100644 --- a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsLoggerProvider.cs +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsLoggerProvider.cs @@ -1,8 +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; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Abstractions.Internal; using Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal; +using Serilog; namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics { @@ -13,31 +16,44 @@ public class AzureWebAppDiagnosticsLoggerProvider : ILoggerProvider { private readonly IWebAppLogConfigurationReader _configurationReader; - private readonly ILoggerProvider _innerLoggerProvider; - private readonly bool _runningInWebApp; + private readonly LoggerFactory _loggerFactory; /// /// Creates a new instance of the class. /// - public AzureWebAppDiagnosticsLoggerProvider(WebAppContext context, int fileSizeLimitMb) + public AzureWebAppDiagnosticsLoggerProvider(WebAppContext context, AzureWebAppDiagnosticsSettings settings) { + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + _configurationReader = new WebAppLogConfigurationReader(context); var config = _configurationReader.Current; - _runningInWebApp = config.IsRunningInWebApp; + var runningInWebApp = config.IsRunningInWebApp; - if (!_runningInWebApp) - { - _innerLoggerProvider = NullLoggerProvider.Instance; - } - else + if (runningInWebApp) { - _innerLoggerProvider = new FileLoggerProvider(_configurationReader, fileSizeLimitMb); + _loggerFactory = new LoggerFactory(); + var fileLoggerProvider = new FileLoggerProvider( + settings.FileSizeLimit, + settings.RetainedFileCountLimit, + settings.BackgroundQueueSize, + settings.OutputTemplate); + _loggerFactory.AddSerilog(fileLoggerProvider.ConfigureLogger(_configurationReader)); if (!string.IsNullOrEmpty(config.BlobContainerUrl)) { - // TODO: Add the blob logger by creating a composite inner logger which calls - // both loggers + var blobLoggerProvider = new AzureBlobLoggerProvider( + settings.OutputTemplate, + context.SiteName, + context.SiteInstanceId, + settings.BlobName, + settings.BlobBatchSize, + settings.BackgroundQueueSize, + settings.BlobCommitPeriod); + _loggerFactory.AddSerilog(blobLoggerProvider.ConfigureLogger(_configurationReader)); } } } @@ -45,13 +61,13 @@ public AzureWebAppDiagnosticsLoggerProvider(WebAppContext context, int fileSizeL /// public ILogger CreateLogger(string categoryName) { - return _innerLoggerProvider.CreateLogger(categoryName); + return _loggerFactory?.CreateLogger(categoryName) ?? NullLogger.Instance; } /// public void Dispose() { - _innerLoggerProvider.Dispose(); + _loggerFactory?.Dispose(); _configurationReader.Dispose(); } } diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsSettings.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsSettings.cs new file mode 100644 index 00000000..3ee7e71f --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/AzureWebAppDiagnosticsSettings.cs @@ -0,0 +1,46 @@ +// 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 +{ + /// + /// Settings for . + /// + public class AzureWebAppDiagnosticsSettings + { + /// + /// Gets or sets a strictly positive value representing the maximum log size in bytes. Once the log is full, no more message will be appended. + /// + public int FileSizeLimit { get; set; } = 10 * 1024 * 1024; + + /// + /// Gets or sets a strictly positive value representing the maximum retained file count. + /// + public int RetainedFileCountLimit { get; set; } = 2; + + /// + /// Gets or sets a message template describing the output messages. + /// + public string OutputTemplate { get; set; } = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level}] {Message}{NewLine}{Exception}"; + + /// + /// Gets or sets a maximum number of events to include in a single blob append batch. + /// + public int BlobBatchSize { get; set; } = 32; + + /// + /// Gets or sets a time to wait between checking for blob log batches. + /// + public TimeSpan BlobCommitPeriod { get; set; } = TimeSpan.FromSeconds(5); + + /// + /// Gets or sets the last section of log blob name. + /// + public string BlobName { get; set; } = "applicationLog.txt"; + + /// Gets of sets the maximum size of the background log message queue. + public int BackgroundQueueSize { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/AzureBlobLoggerProvider.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/AzureBlobLoggerProvider.cs new file mode 100644 index 00000000..07ac3eea --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/AzureBlobLoggerProvider.cs @@ -0,0 +1,99 @@ +// 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.Formatting.Display; +using Microsoft.WindowsAzure.Storage.Blob; + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal +{ + /// + /// The implemenation of logger provider that creates instances of . + /// + public class AzureBlobLoggerProvider + { + private readonly string _outputTemplate; + private readonly string _appName; + private readonly string _instanceId; + private readonly string _fileName; + private readonly int _batchSize; + private readonly int _backgroundQueueSize; + private readonly TimeSpan _period; + + /// + /// Creates a new instance of the class. + /// + /// A message template describing the output messages + /// The application name to use in blob name + /// The application instance id to use in blob name + /// The last section in log blob name + /// A maximum number of events to include in a single blob append batch + /// The maximum size of the background queue + /// A time to wait between checking for blob log batches + public AzureBlobLoggerProvider(string outputTemplate, string appName, string instanceId, string fileName, int batchSize, int backgroundQueueSize, TimeSpan period) + { + if (outputTemplate == null) + { + throw new ArgumentNullException(nameof(outputTemplate)); + } + if (appName == null) + { + throw new ArgumentNullException(nameof(appName)); + } + if (instanceId == null) + { + throw new ArgumentNullException(nameof(instanceId)); + } + if (fileName == null) + { + throw new ArgumentNullException(nameof(fileName)); + } + if (batchSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(batchSize), $"{nameof(batchSize)} should be a positive number."); + } + if (period <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(period), $"{nameof(period)} should be longer than zero."); + } + + _outputTemplate = outputTemplate; + _appName = appName; + _instanceId = instanceId; + _fileName = fileName; + _batchSize = batchSize; + _backgroundQueueSize = backgroundQueueSize; + _period = period; + } + + /// + public Logger ConfigureLogger(IWebAppLogConfigurationReader reader) + { + var messageFormatter = new MessageTemplateTextFormatter(_outputTemplate, null); + var container = new CloudBlobContainer(new Uri(reader.Current.BlobContainerUrl)); + var fileName = _instanceId + "-" + _fileName; + var azureBlobSink = new AzureBlobSink( + name => new BlobAppendReferenceWrapper(container.GetAppendBlobReference(name)), + _appName, + fileName, + messageFormatter, + _batchSize, + _period); + + var backgroundSink = new BackgroundSink(azureBlobSink, _backgroundQueueSize); + var loggerConfiguration = new LoggerConfiguration(); + + loggerConfiguration.WriteTo.Sink(backgroundSink); + loggerConfiguration.MinimumLevel.ControlledBy(new WebConfigurationReaderLevelSwitch(reader, + configuration => + { + return configuration.BlobLoggingEnabled ? configuration.BlobLoggingLevel : LogLevel.None; + })); + + return loggerConfiguration.CreateLogger(); + } + + } +} \ No newline at end of file diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/AzureBlobSink.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/AzureBlobSink.cs new file mode 100644 index 00000000..cb137ba8 --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/AzureBlobSink.cs @@ -0,0 +1,111 @@ +// 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.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.WindowsAzure.Storage; +using Serilog.Core; +using Serilog.Events; +using Serilog.Formatting; +using Serilog.Sinks.PeriodicBatching; + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal +{ + /// + /// The implemenation that stores messages by appending them to Azure Blob in batches. + /// + public class AzureBlobSink : PeriodicBatchingSink + { + private readonly string _appName; + private readonly string _fileName; + private readonly ITextFormatter _formatter; + private readonly Func _blobReferenceFactory; + + /// + /// Creates a new instance of + /// + /// The container to store logs to. + /// The application name to use in blob path generation. + /// The last segment of blob name. + /// The for log messages. + /// The maximum number of events to include in a single batch. + /// The time to wait between checking for event batches. + public AzureBlobSink(Func blobReferenceFactory, + string appName, + string fileName, + ITextFormatter formatter, + int batchSizeLimit, + TimeSpan period) : base(batchSizeLimit, period) + { + if (appName == null) + { + throw new ArgumentNullException(nameof(appName)); + } + if (fileName == null) + { + throw new ArgumentNullException(nameof(fileName)); + } + if (formatter == null) + { + throw new ArgumentNullException(nameof(formatter)); + } + if (batchSizeLimit <= 0) + { + throw new ArgumentOutOfRangeException(nameof(batchSizeLimit), $"{nameof(batchSizeLimit)} should be a positive number."); + } + if (period <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(period), $"{nameof(period)} should be longer than zero."); + } + + _appName = appName; + _fileName = fileName; + _formatter = formatter; + _blobReferenceFactory = blobReferenceFactory; + } + + /// + protected override async Task EmitBatchAsync(IEnumerable events) + { + var eventGroups = events.GroupBy(GetBlobKey); + foreach (var eventGroup in eventGroups) + { + var key = eventGroup.Key; + var blobName = $"{_appName}/{key.Item1}/{key.Item2:00}/{key.Item3:00}/{key.Item4:00}/{_fileName}"; + + var blob = _blobReferenceFactory(blobName); + + Stream stream; + try + { + stream = await blob.OpenWriteAsync(); + } + // Blob does not exist + catch (StorageException ex) when (ex.RequestInformation.HttpStatusCode == 404) + { + await blob.CreateAsync(); + stream = await blob.OpenWriteAsync(); + } + + using (var writer = new StreamWriter(stream)) + { + foreach (var logEvent in eventGroup) + { + _formatter.Format(logEvent, writer); + } + } + } + } + + private Tuple GetBlobKey(LogEvent e) + { + return Tuple.Create(e.Timestamp.Year, + e.Timestamp.Month, + e.Timestamp.Day, + e.Timestamp.Hour); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/BlobAppendReferenceWrapper.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/BlobAppendReferenceWrapper.cs new file mode 100644 index 00000000..8c3bf1d1 --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/BlobAppendReferenceWrapper.cs @@ -0,0 +1,36 @@ +// 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.IO; +using System.Threading.Tasks; +using Microsoft.WindowsAzure.Storage; +using Microsoft.WindowsAzure.Storage.Blob; + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal +{ + /// + public class BlobAppendReferenceWrapper : ICloudAppendBlob + { + private readonly CloudAppendBlob _cloudAppendBlob; + + /// + /// Creates new instance of . + /// + /// The instance to wrap. + public BlobAppendReferenceWrapper(CloudAppendBlob cloudAppendBlob) + { + _cloudAppendBlob = cloudAppendBlob; + } + /// + public async Task OpenWriteAsync() + { + return await _cloudAppendBlob.OpenWriteAsync(createNew: false); + } + + /// + public async Task CreateAsync() + { + await _cloudAppendBlob.CreateOrReplaceAsync(AccessCondition.GenerateIfNotExistsCondition(), options: null, operationContext: null); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/FileLoggerProvider.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/FileLoggerProvider.cs index f1a12ff6..10c7241e 100644 --- a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/FileLoggerProvider.cs +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/FileLoggerProvider.cs @@ -4,69 +4,81 @@ using System; using System.IO; using Serilog; +using Serilog.Core; using Serilog.Formatting.Display; using Serilog.Sinks.RollingFile; namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal { /// - /// A file logger for Azure WebApp. + /// The logger provider that creates instances of . /// - public class FileLoggerProvider : SerilogLoggerProvider + public class FileLoggerProvider { - /// - /// 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 readonly int _fileSizeLimit; + private readonly int _retainedFileCountLimit; + private readonly int _backgroundQueueSize; + private readonly string _outputTemplate; - 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) => + /// A strictly positive value representing the maximum retained file count + /// The maximum size of the background queue + /// A message template describing the output messages + public FileLoggerProvider(int fileSizeLimit, int retainedFileCountLimit, int backgroundQueueSize, string outputTemplate) + { + if (outputTemplate == null) { - 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); + throw new ArgumentNullException(nameof(outputTemplate)); + } + if (fileSizeLimit <= 0) + { + throw new ArgumentOutOfRangeException(nameof(fileSizeLimit), $"{nameof(fileSizeLimit)} should be positive."); + } + if (retainedFileCountLimit <= 0) + { + throw new ArgumentOutOfRangeException(nameof(retainedFileCountLimit), $"{nameof(retainedFileCountLimit)} should be positive."); + } - loggerConfiguration.WriteTo.Sink(backgroundSink); - }) - { + _fileSizeLimit = fileSizeLimit; + _retainedFileCountLimit = retainedFileCountLimit; + _backgroundQueueSize = backgroundQueueSize; + _outputTemplate = outputTemplate; } /// - protected override void OnConfigurationChanged(WebAppLogConfiguration newConfiguration) + public Logger ConfigureLogger(IWebAppLogConfigurationReader reader) { - if (!newConfiguration.FileLoggingEnabled) + var webAppConfiguration = reader.Current; + if (string.IsNullOrEmpty(webAppConfiguration.FileLoggingFolder)) { - LevelSwitcher.MinimumLevel = LogLevelDisabled; + throw new ArgumentNullException(nameof(webAppConfiguration.FileLoggingFolder), + "The file logger path cannot be null or empty."); } - else + + var logsFolder = webAppConfiguration.FileLoggingFolder; + if (!Directory.Exists(logsFolder)) { - LevelSwitcher.MinimumLevel = LogLevelToLogEventLevel(newConfiguration.FileLoggingLevel); + Directory.CreateDirectory(logsFolder); } + var logsFilePattern = Path.Combine(logsFolder, FileNamePattern); + + var messageFormatter = new MessageTemplateTextFormatter(_outputTemplate, null); + var rollingFileSink = new RollingFileSink(logsFilePattern, messageFormatter, _fileSizeLimit, _retainedFileCountLimit); + var backgroundSink = new BackgroundSink(rollingFileSink, _backgroundQueueSize); + + var loggerConfiguration = new LoggerConfiguration(); + loggerConfiguration.WriteTo.Sink(backgroundSink); + loggerConfiguration.MinimumLevel.ControlledBy(new WebConfigurationReaderLevelSwitch(reader, + configuration => + { + return configuration.FileLoggingEnabled ? configuration.FileLoggingLevel : LogLevel.None; + })); + return loggerConfiguration.CreateLogger(); } } } diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/ICloudAppendBlob.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/ICloudAppendBlob.cs new file mode 100644 index 00000000..b03f9920 --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/ICloudAppendBlob.cs @@ -0,0 +1,26 @@ +// 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.IO; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal +{ + /// + /// Represents an append blob, a type of blob where blocks of data are always committed to the end of the blob. + /// + public interface ICloudAppendBlob + { + /// + /// Initiates an asynchronous operation to open a stream for writing to the blob. + /// + /// A object of type that represents the asynchronous operation. + Task OpenWriteAsync(); + + /// + /// Initiates an asynchronous operation to create an empty append blob. + /// + /// A object that represents the asynchronous operation. + Task CreateAsync(); + } +} \ No newline at end of file diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/IWebAppContext.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/IWebAppContext.cs index 7af58cf3..ee64358a 100644 --- a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/IWebAppContext.cs +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/IWebAppContext.cs @@ -13,6 +13,16 @@ public interface IWebAppContext /// string HomeFolder { get; } + /// + /// Gets the name of site if running in Azure WebApp + /// + string SiteName { get; } + + /// + /// Gets the id of site if running in Azure WebApp + /// + string SiteInstanceId { get; } + /// /// Gets a value indicating whether or new we're in an Azure WebApp /// diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/SerilogLoggerProvider.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/SerilogLoggerProvider.cs deleted file mode 100644 index 6316742b..00000000 --- a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/SerilogLoggerProvider.cs +++ /dev/null @@ -1,125 +0,0 @@ -// 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 index 4d473dbf..cd4e58c3 100644 --- a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppContext.cs +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppContext.cs @@ -21,14 +21,13 @@ private WebAppContext() { } public string HomeFolder { get; } = Environment.GetEnvironmentVariable("HOME"); /// - public bool IsRunningInAzureWebApp - { - get - { - return - !string.IsNullOrEmpty(HomeFolder) && - !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME")); - } - } + public string SiteName { get; } = Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME"); + + /// + public string SiteInstanceId { get; } = Environment.GetEnvironmentVariable("WEBSITE_INSTANCE_ID"); + + /// + public bool IsRunningInAzureWebApp => !string.IsNullOrEmpty(HomeFolder) && + !string.IsNullOrEmpty(SiteName); } } diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfiguration.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfiguration.cs index 12b7545b..48cf489f 100644 --- a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfiguration.cs +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfiguration.cs @@ -14,12 +14,12 @@ public class WebAppLogConfiguration public static WebAppLogConfiguration Disabled { get; } = new WebAppLogConfigurationBuilder().Build(); internal WebAppLogConfiguration( - bool isRunningInWebApp, + bool isRunningInWebApp, bool fileLoggingEnabled, - LogLevel fileLoggingLevel, + LogLevel fileLoggingLevel, string fileLoggingFolder, - bool blobLoggingEnabled, - LogLevel blobLoggingLevel, + bool blobLoggingEnabled, + LogLevel blobLoggingLevel, string blobContainerUrl) { IsRunningInWebApp = isRunningInWebApp; diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfigurationReader.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfigurationReader.cs index db519f9e..95a31205 100644 --- a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfigurationReader.cs +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebAppLogConfigurationReader.cs @@ -79,10 +79,7 @@ private void OnConfigurationTokenChange(object state) ReloadConfiguration(); SubscribeToConfigurationChangeEvent(); - if (OnConfigurationChanged != null) - { - OnConfigurationChanged(this, _latestConfiguration); - } + OnConfigurationChanged?.Invoke(this, _latestConfiguration); } private void SubscribeToConfigurationChangeEvent() diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebConfigurationReaderLevelSwitch.cs b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebConfigurationReaderLevelSwitch.cs new file mode 100644 index 00000000..f4710d4d --- /dev/null +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/Internal/WebConfigurationReaderLevelSwitch.cs @@ -0,0 +1,69 @@ +// 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.Core; +using Serilog.Events; + +namespace Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal +{ + /// + /// The implementation that runs callback + /// when event fires. + /// + public class WebConfigurationReaderLevelSwitch : LoggingLevelSwitch + { + /// + /// The log level at which the logger is disabled. + /// + private static readonly LogEventLevel LogLevelDisabled = LogEventLevel.Fatal + 1; + + /// + /// Creates a new instance of the class. + /// + /// + /// + public WebConfigurationReaderLevelSwitch(IWebAppLogConfigurationReader reader, Func convert ) + { + reader.OnConfigurationChanged += (sender, configuration) => + { + MinimumLevel = LogLevelToLogEventLevel(convert(configuration)); + }; + } + + /// + /// Converts a object to . + /// + /// The log level to convert + /// A instance + private 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}"); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/project.json b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/project.json index af5a6079..3e9b7ce8 100644 --- a/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/project.json +++ b/src/Microsoft.Extensions.Logging.AzureWebAppDiagnostics/project.json @@ -17,7 +17,9 @@ "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" + "Serilog.Sinks.RollingFile": "2.1.0", + "Serilog.Sinks.PeriodicBatching": "2.0.0", + "WindowsAzure.Storage": "7.2.0" }, "frameworks": { "net451": { @@ -31,7 +33,8 @@ "dependencies": { "System.Console": "4.0.0-*", "System.Threading.Thread": "4.0.0-*" - } + }, + "imports": [ "portable-net40+sl5+win8+wp8+wpa81", "portable-net45+win8+wp8+wpa81" ] } } } \ No newline at end of file diff --git a/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/AzureBlobSinkTests.cs b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/AzureBlobSinkTests.cs new file mode 100644 index 00000000..8431c205 --- /dev/null +++ b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/AzureBlobSinkTests.cs @@ -0,0 +1,181 @@ +// 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.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Internal; +using Microsoft.WindowsAzure.Storage; +using Moq; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using Serilog.Formatting.Display; +using Serilog.Parsing; +using Xunit; + +namespace Microsoft.Extensions.Logging.AzureWebApps.Test +{ + public class AzureBlobSinkTests + { + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); + + [Fact] + public void WritesMessagesInBatches() + { + var blob = new Mock(); + var buffers = new List(); + blob.Setup(b => b.OpenWriteAsync()).Returns(() => Task.FromResult((Stream)new TestMemoryStream(buffers))); + + var sink = new TestAzureBlobSink(name => blob.Object, 5); + var logger = CreateLogger(sink); + + for (int i = 0; i < 5; i++) + { + logger.Information("Text " + i); + } + + Assert.True(sink.CountdownEvent.Wait(DefaultTimeout)); + +#if NET451 + Assert.Equal(1, buffers.Count); + Assert.Equal(Encoding.UTF8.GetString(buffers[0]), @"Information Text 0 +Information Text 1 +Information Text 2 +Information Text 3 +Information Text 4 +"); +#else + // PeriodicBatchingSink always writes first message as seperate batch on coreclr + // https://github.com/serilog/serilog-sinks-periodicbatching/issues/7 + Assert.Equal(2, buffers.Count); + Assert.Equal(Encoding.UTF8.GetString(buffers[0]), @"Information Text 0" + Environment.NewLine); + Assert.Equal(Encoding.UTF8.GetString(buffers[1]), @"Information Text 1 +Information Text 2 +Information Text 3 +Information Text 4 +"); +#endif + } + + [Fact] + public void GroupsByHour() + { + var blob = new Mock(); + var buffers = new List(); + var names = new List(); + + blob.Setup(b => b.OpenWriteAsync()).Returns(() => Task.FromResult((Stream)new TestMemoryStream(buffers))); + + var sink = new TestAzureBlobSink(name => + { + names.Add(name); + return blob.Object; + }, 3); + var logger = CreateLogger(sink); + + var startDate = new DateTime(2016, 8, 29, 22, 0, 0); + for (int i = 0; i < 3; i++) + { + var addHours = startDate.AddHours(i); + logger.Write(new LogEvent( + new DateTimeOffset(addHours), + LogEventLevel.Information, + null, + new MessageTemplate("Text", Enumerable.Empty()), + Enumerable.Empty())); + } + + Assert.True(sink.CountdownEvent.Wait(DefaultTimeout)); + + Assert.Equal(3, buffers.Count); + + Assert.Equal("appname/2016/08/29/22/filename", names[0]); + Assert.Equal("appname/2016/08/29/23/filename", names[1]); + Assert.Equal("appname/2016/08/30/00/filename", names[2]); + } + + [Fact] + public void CreatesBlobIfNotExists() + { + var blob = new Mock(); + var buffers = new List(); + bool created = false; + + blob.Setup(b => b.OpenWriteAsync()).Returns(() => + { + if (!created) + { + throw new StorageException(new RequestResult() { HttpStatusCode = 404 }, string.Empty, null); + } + return Task.FromResult((Stream) new TestMemoryStream(buffers)); + }); + + blob.Setup(b => b.CreateAsync()).Returns(() => + { + created = true; + return Task.FromResult(0); + }); + + var sink = new TestAzureBlobSink((name) => blob.Object, 1); + var logger = CreateLogger(sink); + logger.Information("Text"); + + Assert.True(sink.CountdownEvent.Wait(DefaultTimeout)); + + Assert.Equal(1, buffers.Count); + Assert.Equal(true, created); + } + + private static Logger CreateLogger(AzureBlobSink sink) + { + var loggerConfiguration = new LoggerConfiguration(); + loggerConfiguration.WriteTo.Sink(sink); + var logger = loggerConfiguration.CreateLogger(); + return logger; + } + + private class TestAzureBlobSink: AzureBlobSink + { + public CountdownEvent CountdownEvent { get; } + + public TestAzureBlobSink(Func blob, int count):base( + blob, + "appname", + "filename", + new MessageTemplateTextFormatter("{Level} {Message}{NewLine}", CultureInfo.InvariantCulture), + 10, + TimeSpan.FromSeconds(0.1)) + { + CountdownEvent = new CountdownEvent(count); + } + + protected override async Task EmitBatchAsync(IEnumerable events) + { + await base.EmitBatchAsync(events); + CountdownEvent.Signal(events.Count()); + } + } + + private class TestMemoryStream : MemoryStream + { + public List Buffers { get; } + + public TestMemoryStream(List buffers) + { + Buffers = buffers; + } + + protected override void Dispose(bool disposing) + { + Buffers.Add(ToArray()); + base.Dispose(disposing); + } + } + } +} diff --git a/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/SerilogLoggerProviderTests.cs b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/SerilogLoggerProviderTests.cs index e7b2bf10..4954f303 100644 --- a/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/SerilogLoggerProviderTests.cs +++ b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/SerilogLoggerProviderTests.cs @@ -22,11 +22,9 @@ public void OnStartDisable() // 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"); - } + var provider = new TestWebAppSerilogLoggerProvider(testSink.Object); + var logger = provider.ConfigureLogger(configReader.Object); + logger.Information("Test"); } [Fact] @@ -40,15 +38,13 @@ public void OnStartLoggingLevel() 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"); - } + var provider = new TestWebAppSerilogLoggerProvider(testSink.Object); + var logger = provider.ConfigureLogger(configReader.Object); + logger.Information("Test"); testSink.Verify(m => m.Emit(It.IsAny()), Times.Once); } @@ -70,11 +66,11 @@ public void DynamicDisable() var testSink = new Mock(MockBehavior.Strict); testSink.Setup(m => m.Emit(It.IsAny())); - using (var provider = new TestWebAppSerilogLoggerProvider(testSink.Object, configReader.Object)) + var provider = new TestWebAppSerilogLoggerProvider(testSink.Object); { - var logger = provider.CreateLogger("TestLogger"); + var logger = provider.ConfigureLogger(configReader.Object); - logger.LogInformation("Test1"); + logger.Information("Test1"); testSink.Verify(m => m.Emit(It.IsAny()), Times.Once); configBuilder.SetFileLoggingEnabled(false); @@ -83,7 +79,7 @@ public void DynamicDisable() configReader.Raise(m => m.OnConfigurationChanged += (sender, e) => { }, null, currentConfig); // Logging should be disabled now - logger.LogInformation("Test1"); + logger.Information("Test1"); testSink.Verify(m => m.Emit(It.IsAny()), Times.Once); } } @@ -92,11 +88,11 @@ public void DynamicDisable() public void DynamicLoggingLevel() { var configBuilder = new WebAppLogConfigurationBuilder() - .SetIsRunningInAzureWebApps(true) - .SetFileLoggingEnabled(true) - .SetFileLoggingLevel(LogLevel.Critical); + .SetIsRunningInAzureWebApps(true) + .SetFileLoggingEnabled(true) + .SetFileLoggingLevel(LogLevel.Critical); - var currentConfig = configBuilder.Build(); + var currentConfig = configBuilder.Build(); var configReader = new Mock(); configReader.SetupGet(m => m.Current) @@ -105,22 +101,20 @@ public void DynamicLoggingLevel() 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"); + var provider = new TestWebAppSerilogLoggerProvider(testSink.Object); + var logger = provider.ConfigureLogger(configReader.Object); - logger.LogDebug("Test1"); - testSink.Verify(m => m.Emit(It.IsAny()), Times.Never); + logger.Debug("Test1"); + testSink.Verify(m => m.Emit(It.IsAny()), Times.Never); - configBuilder.SetFileLoggingLevel(LogLevel.Debug); - currentConfig = configBuilder.Build(); + configBuilder.SetFileLoggingLevel(LogLevel.Debug); + currentConfig = configBuilder.Build(); - configReader.Raise(m => m.OnConfigurationChanged += (sender, e) => { }, null, currentConfig); + 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); - } + // Logging for this level should be enabled now + logger.Debug("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 @@ -128,8 +122,8 @@ public void DynamicLoggingLevel() public void LevelMapping() { var configBuilder = new WebAppLogConfigurationBuilder() - .SetIsRunningInAzureWebApps(true) - .SetFileLoggingEnabled(true); + .SetIsRunningInAzureWebApps(true) + .SetFileLoggingEnabled(true); var currentConfig = configBuilder.Build(); @@ -140,68 +134,69 @@ public void LevelMapping() var testSink = new Mock(MockBehavior.Strict); testSink.Setup(m => m.Emit(It.IsAny())); - using (var provider = new TestWebAppSerilogLoggerProvider(testSink.Object, configReader.Object)) + var provider = new TestWebAppSerilogLoggerProvider(testSink.Object); + var levelsToCheck = new [] { - 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]; + LogLevel.None, + LogLevel.Critical, + LogLevel.Error, + LogLevel.Warning, + LogLevel.Information, + LogLevel.Debug, + LogLevel.Trace + }; + + var seriloglogger = provider.ConfigureLogger(configReader.Object); + var loggerFactory = new LoggerFactory(); + loggerFactory.AddSerilog(seriloglogger); + var logger = loggerFactory.CreateLogger("TestLogger"); + + for (var 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); + // 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); - } + // Don't try to log "None" (start at 1) + for (var 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}"); + // 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(); - } + testSink.ResetCalls(); } } - private class TestWebAppSerilogLoggerProvider : SerilogLoggerProvider + private class TestWebAppSerilogLoggerProvider { - public TestWebAppSerilogLoggerProvider(ILogEventSink sink, IWebAppLogConfigurationReader configReader) : - base(configReader, - (logger, config) => - { - logger.WriteTo.Sink(sink); - }) + private readonly ILogEventSink _sink; + + public TestWebAppSerilogLoggerProvider(ILogEventSink sink) { + _sink = sink; } - protected override void OnConfigurationChanged(WebAppLogConfiguration newConfiguration) + public Logger ConfigureLogger(IWebAppLogConfigurationReader reader) { - if (!newConfiguration.FileLoggingEnabled) - { - LevelSwitcher.MinimumLevel = LogLevelDisabled; - } - else - { - LevelSwitcher.MinimumLevel = LogLevelToLogEventLevel(newConfiguration.FileLoggingLevel); - } + var loggerConfiguration = new LoggerConfiguration(); + loggerConfiguration.WriteTo.Sink(_sink); + loggerConfiguration.MinimumLevel.ControlledBy(new WebConfigurationReaderLevelSwitch(reader, + configuration => + { + return configuration.FileLoggingEnabled ? configuration.FileLoggingLevel : LogLevel.None; + })); + + return loggerConfiguration.CreateLogger(); } } } diff --git a/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/project.json b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/project.json index 7e3ab744..ac5eff87 100644 --- a/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/project.json +++ b/test/Microsoft.Extensions.Logging.AzureWebAppDiagnostics.Test/project.json @@ -17,7 +17,8 @@ "version": "1.0.0-*", "type": "platform" } - } + }, + "imports": [ "portable-net40+sl5+win8+wp8+wpa81", "portable-net45+win8+wp8+wpa81" ] }, "net451": {} }