diff --git a/eng/Build.props b/eng/Build.props
index 6f1b3f1908..ef89408b47 100644
--- a/eng/Build.props
+++ b/eng/Build.props
@@ -152,6 +152,7 @@
$(RepoRoot)src\SiteExtensions\LoggingAggregate\test\**\*.csproj;
$(RepoRoot)src\Shared\**\*.*proj;
$(RepoRoot)src\Tools\**\*.*proj;
+ $(RepoRoot)src\Logging.AzureAppServices\**\src\*.csproj;
$(RepoRoot)src\Middleware\**\*.csproj;
$(RepoRoot)src\Razor\**\*.*proj;
$(RepoRoot)src\Mvc\**\*.*proj;
@@ -191,6 +192,7 @@
$(RepoRoot)src\Security\**\src\*.csproj;
$(RepoRoot)src\SiteExtensions\**\src\*.csproj;
$(RepoRoot)src\Tools\**\src\*.csproj;
+ $(RepoRoot)src\Logging.AzureAppServices\**\src\*.csproj;
$(RepoRoot)src\Middleware\**\src\*.csproj;
$(RepoRoot)src\Razor\**\src\*.csproj;
$(RepoRoot)src\Mvc\**\src\*.csproj;
diff --git a/eng/Dependencies.props b/eng/Dependencies.props
index 557742686a..6013eeca3d 100644
--- a/eng/Dependencies.props
+++ b/eng/Dependencies.props
@@ -119,8 +119,6 @@ and are generated based on the last package release.
-
-
diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props
index dda9b06ce8..a7d42c3782 100644
--- a/eng/ProjectReferences.props
+++ b/eng/ProjectReferences.props
@@ -32,6 +32,7 @@
+
diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml
index b9db393cf2..5ab63a957a 100644
--- a/eng/Version.Details.xml
+++ b/eng/Version.Details.xml
@@ -161,10 +161,6 @@
https://github.com/dotnet/extensions
03c40031d618f923aa88da125cb078aabde9ebb1
-
- https://github.com/dotnet/extensions
- 03c40031d618f923aa88da125cb078aabde9ebb1
-
https://github.com/dotnet/extensions
03c40031d618f923aa88da125cb078aabde9ebb1
@@ -189,10 +185,6 @@
https://github.com/dotnet/extensions
03c40031d618f923aa88da125cb078aabde9ebb1
-
- https://github.com/dotnet/extensions
- 03c40031d618f923aa88da125cb078aabde9ebb1
-
https://github.com/dotnet/extensions
03c40031d618f923aa88da125cb078aabde9ebb1
diff --git a/eng/Versions.props b/eng/Versions.props
index fb76821475..737b0be4fb 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -123,14 +123,12 @@
5.0.0-preview.4.20201.2
5.0.0-preview.4.20201.2
5.0.0-preview.4.20201.2
- 5.0.0-preview.4.20201.2
5.0.0-preview.4.20201.2
5.0.0-preview.4.20201.2
5.0.0-preview.4.20201.2
5.0.0-preview.4.20201.2
5.0.0-preview.4.20201.2
5.0.0-preview.4.20201.2
- 5.0.0-preview.4.20201.2
5.0.0-preview.4.20201.2
5.0.0-preview.4.20201.2
5.0.0-preview.4.20201.2
diff --git a/src/Components/test/testassets/TestServer/Components.TestServer.csproj b/src/Components/test/testassets/TestServer/Components.TestServer.csproj
index 4eebc1f24b..d51a812210 100644
--- a/src/Components/test/testassets/TestServer/Components.TestServer.csproj
+++ b/src/Components/test/testassets/TestServer/Components.TestServer.csproj
@@ -14,8 +14,8 @@
+
-
diff --git a/src/Hosting/Server.IntegrationTesting/src/Microsoft.AspNetCore.Server.IntegrationTesting.csproj b/src/Hosting/Server.IntegrationTesting/src/Microsoft.AspNetCore.Server.IntegrationTesting.csproj
index ba625f4332..939dfcf376 100644
--- a/src/Hosting/Server.IntegrationTesting/src/Microsoft.AspNetCore.Server.IntegrationTesting.csproj
+++ b/src/Hosting/Server.IntegrationTesting/src/Microsoft.AspNetCore.Server.IntegrationTesting.csproj
@@ -23,7 +23,6 @@
-
diff --git a/src/Logging.AzureAppServices/Directory.Build.props b/src/Logging.AzureAppServices/Directory.Build.props
new file mode 100644
index 0000000000..68f87d4f24
--- /dev/null
+++ b/src/Logging.AzureAppServices/Directory.Build.props
@@ -0,0 +1,8 @@
+
+
+
+
+
+ true
+
+
diff --git a/src/Logging.AzureAppServices/src/AzureAppServicesLoggerFactoryExtensions.cs b/src/Logging.AzureAppServices/src/AzureAppServicesLoggerFactoryExtensions.cs
new file mode 100644
index 0000000000..9b680e9138
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/AzureAppServicesLoggerFactoryExtensions.cs
@@ -0,0 +1,98 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Logging.AzureAppServices;
+using Microsoft.Extensions.Logging.Configuration;
+using Microsoft.Extensions.Options;
+using static Microsoft.Extensions.DependencyInjection.ServiceDescriptor;
+
+namespace Microsoft.Extensions.Logging
+{
+ ///
+ /// Extension methods for adding Azure diagnostics logger.
+ ///
+ public static class AzureAppServicesLoggerFactoryExtensions
+ {
+ ///
+ /// Adds an Azure Web Apps diagnostics logger.
+ ///
+ /// The extension method argument
+ public static ILoggingBuilder AddAzureWebAppDiagnostics(this ILoggingBuilder builder)
+ {
+ var context = WebAppContext.Default;
+
+ // Only add the provider if we're in Azure WebApp. That cannot change once the apps started
+ return AddAzureWebAppDiagnostics(builder, context);
+ }
+
+ internal static ILoggingBuilder AddAzureWebAppDiagnostics(this ILoggingBuilder builder, IWebAppContext context)
+ {
+ if (!context.IsRunningInAzureWebApp)
+ {
+ return builder;
+ }
+
+ builder.AddConfiguration();
+
+ var config = SiteConfigurationProvider.GetAzureLoggingConfiguration(context);
+ var services = builder.Services;
+
+ var addedFileLogger = TryAddEnumerable(services, Singleton());
+ var addedBlobLogger = TryAddEnumerable(services, Singleton());
+
+ if (addedFileLogger || addedBlobLogger)
+ {
+ services.AddSingleton(context);
+ services.AddSingleton>(
+ new ConfigurationChangeTokenSource(config));
+ }
+
+ if (addedFileLogger)
+ {
+ services.AddSingleton>(CreateFileFilterConfigureOptions(config));
+ services.AddSingleton>(new FileLoggerConfigureOptions(config, context));
+ services.AddSingleton>(
+ new ConfigurationChangeTokenSource(config));
+ LoggerProviderOptions.RegisterProviderOptions(builder.Services);
+ }
+
+ if (addedBlobLogger)
+ {
+ services.AddSingleton>(CreateBlobFilterConfigureOptions(config));
+ services.AddSingleton>(new BlobLoggerConfigureOptions(config, context));
+ services.AddSingleton>(
+ new ConfigurationChangeTokenSource(config));
+ LoggerProviderOptions.RegisterProviderOptions(builder.Services);
+ }
+
+ return builder;
+ }
+
+ private static bool TryAddEnumerable(IServiceCollection collection, ServiceDescriptor descriptor)
+ {
+ var beforeCount = collection.Count;
+ collection.TryAddEnumerable(descriptor);
+ return beforeCount != collection.Count;
+ }
+
+ private static ConfigurationBasedLevelSwitcher CreateBlobFilterConfigureOptions(IConfiguration config)
+ {
+ return new ConfigurationBasedLevelSwitcher(
+ configuration: config,
+ provider: typeof(BlobLoggerProvider),
+ levelKey: "AzureBlobTraceLevel");
+ }
+
+ private static ConfigurationBasedLevelSwitcher CreateFileFilterConfigureOptions(IConfiguration config)
+ {
+ return new ConfigurationBasedLevelSwitcher(
+ configuration: config,
+ provider: typeof(FileLoggerProvider),
+ levelKey: "AzureDriveTraceLevel");
+ }
+ }
+}
diff --git a/src/Logging.AzureAppServices/src/AzureBlobLoggerOptions.cs b/src/Logging.AzureAppServices/src/AzureBlobLoggerOptions.cs
new file mode 100644
index 0000000000..1e1285b358
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/AzureBlobLoggerOptions.cs
@@ -0,0 +1,39 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices
+{
+ ///
+ /// Options for Azure diagnostics blob logging.
+ ///
+ public class AzureBlobLoggerOptions: BatchingLoggerOptions
+ {
+ private string _blobName = "applicationLog.txt";
+
+ ///
+ /// Gets or sets the last section of log blob name.
+ /// Defaults to "applicationLog.txt".
+ ///
+ public string BlobName
+ {
+ get { return _blobName; }
+ set
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ throw new ArgumentException(nameof(value), $"{nameof(BlobName)} must be non-empty string.");
+ }
+ _blobName = value;
+ }
+ }
+
+ internal string ContainerUrl { get; set; }
+
+ internal string ApplicationName { get; set; }
+
+ internal string ApplicationInstanceId { get; set; }
+ }
+}
diff --git a/src/Logging.AzureAppServices/src/AzureFileLoggerOptions.cs b/src/Logging.AzureAppServices/src/AzureFileLoggerOptions.cs
new file mode 100644
index 0000000000..af8b5a112e
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/AzureFileLoggerOptions.cs
@@ -0,0 +1,73 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices
+{
+ ///
+ /// Options for Azure diagnostics file logging.
+ ///
+ public class AzureFileLoggerOptions: BatchingLoggerOptions
+ {
+ private int? _fileSizeLimit = 10 * 1024 * 1024;
+ private int? _retainedFileCountLimit = 2;
+ private string _fileName = "diagnostics-";
+
+ ///
+ /// Gets or sets a strictly positive value representing the maximum log size in bytes or null for no limit.
+ /// Once the log is full, no more messages will be appended.
+ /// Defaults to 10MB.
+ ///
+ public int? FileSizeLimit
+ {
+ get { return _fileSizeLimit; }
+ set
+ {
+ if (value <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), $"{nameof(FileSizeLimit)} must be positive.");
+ }
+ _fileSizeLimit = value;
+ }
+ }
+
+ ///
+ /// Gets or sets a strictly positive value representing the maximum retained file count or null for no limit.
+ /// Defaults to 2.
+ ///
+ public int? RetainedFileCountLimit
+ {
+ get { return _retainedFileCountLimit; }
+ set
+ {
+ if (value <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), $"{nameof(RetainedFileCountLimit)} must be positive.");
+ }
+ _retainedFileCountLimit = value;
+ }
+ }
+
+ ///
+ /// Gets or sets a string representing the prefix of the file name used to store the logging information.
+ /// The current date, in the format YYYYMMDD will be added after the given value.
+ /// Defaults to diagnostics-.
+ ///
+ public string FileName
+ {
+ get { return _fileName; }
+ set
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ throw new ArgumentException(nameof(value));
+ }
+ _fileName = value;
+ }
+ }
+
+ internal string LogDirectory { get; set; }
+ }
+}
diff --git a/src/Logging.AzureAppServices/src/BatchLoggerConfigureOptions.cs b/src/Logging.AzureAppServices/src/BatchLoggerConfigureOptions.cs
new file mode 100644
index 0000000000..8dc8727b3a
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/BatchLoggerConfigureOptions.cs
@@ -0,0 +1,37 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices
+{
+ internal class BatchLoggerConfigureOptions : IConfigureOptions
+ {
+ private readonly IConfiguration _configuration;
+ private readonly string _isEnabledKey;
+
+ public BatchLoggerConfigureOptions(IConfiguration configuration, string isEnabledKey)
+ {
+ _configuration = configuration;
+ _isEnabledKey = isEnabledKey;
+ }
+
+ public void Configure(BatchingLoggerOptions options)
+ {
+ options.IsEnabled = TextToBoolean(_configuration.GetSection(_isEnabledKey)?.Value);
+ }
+
+ private static bool TextToBoolean(string text)
+ {
+ if (string.IsNullOrEmpty(text) ||
+ !bool.TryParse(text, out var result))
+ {
+ result = false;
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/src/Logging.AzureAppServices/src/BatchingLogger.cs b/src/Logging.AzureAppServices/src/BatchingLogger.cs
new file mode 100644
index 0000000000..bd192169f3
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/BatchingLogger.cs
@@ -0,0 +1,75 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Text;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices
+{
+ internal class BatchingLogger : ILogger
+ {
+ private readonly BatchingLoggerProvider _provider;
+ private readonly string _category;
+
+ public BatchingLogger(BatchingLoggerProvider loggerProvider, string categoryName)
+ {
+ _provider = loggerProvider;
+ _category = categoryName;
+ }
+
+ public IDisposable BeginScope(TState state)
+ {
+ return null;
+ }
+
+ public bool IsEnabled(LogLevel logLevel)
+ {
+ return _provider.IsEnabled;
+ }
+
+ public void Log(DateTimeOffset timestamp, LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter)
+ {
+ if (!IsEnabled(logLevel))
+ {
+ return;
+ }
+
+ var builder = new StringBuilder();
+ builder.Append(timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff zzz"));
+ builder.Append(" [");
+ builder.Append(logLevel.ToString());
+ builder.Append("] ");
+ builder.Append(_category);
+
+ var scopeProvider = _provider.ScopeProvider;
+ if (scopeProvider != null)
+ {
+ scopeProvider.ForEachScope((scope, stringBuilder) =>
+ {
+ stringBuilder.Append(" => ").Append(scope);
+ }, builder);
+
+ builder.AppendLine(":");
+ }
+ else
+ {
+ builder.Append(": ");
+ }
+
+ builder.AppendLine(formatter(state, exception));
+
+ if (exception != null)
+ {
+ builder.AppendLine(exception.ToString());
+ }
+
+ _provider.AddMessage(timestamp, builder.ToString());
+ }
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter)
+ {
+ Log(DateTimeOffset.Now, logLevel, eventId, state, exception, formatter);
+ }
+ }
+}
diff --git a/src/Logging.AzureAppServices/src/BatchingLoggerOptions.cs b/src/Logging.AzureAppServices/src/BatchingLoggerOptions.cs
new file mode 100644
index 0000000000..9fbd964800
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/BatchingLoggerOptions.cs
@@ -0,0 +1,80 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices
+{
+ ///
+ /// Options for a logger which batches up log messages.
+ ///
+ public class BatchingLoggerOptions
+ {
+ private int? _batchSize;
+ private int? _backgroundQueueSize = 1000;
+ private TimeSpan _flushPeriod = TimeSpan.FromSeconds(1);
+
+ ///
+ /// Gets or sets the period after which logs will be flushed to the store.
+ ///
+ public TimeSpan FlushPeriod
+ {
+ get { return _flushPeriod; }
+ set
+ {
+ if (value <= TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), $"{nameof(FlushPeriod)} must be positive.");
+ }
+ _flushPeriod = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the maximum size of the background log message queue or null for no limit.
+ /// After maximum queue size is reached log event sink would start blocking.
+ /// Defaults to 1000.
+ ///
+ public int? BackgroundQueueSize
+ {
+ get { return _backgroundQueueSize; }
+ set
+ {
+ if (value < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), $"{nameof(BackgroundQueueSize)} must be non-negative.");
+ }
+ _backgroundQueueSize = value;
+ }
+ }
+
+ ///
+ /// Gets or sets a maximum number of events to include in a single batch or null for no limit.
+ ///
+ /// Defaults to null.
+ public int? BatchSize
+ {
+ get { return _batchSize; }
+ set
+ {
+ if (value <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), $"{nameof(BatchSize)} must be positive.");
+ }
+ _batchSize = value;
+ }
+ }
+
+ ///
+ /// Gets or sets value indicating if logger accepts and queues writes.
+ ///
+ public bool IsEnabled { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether scopes should be included in the message.
+ /// Defaults to false.
+ ///
+ public bool IncludeScopes { get; set; } = false;
+ }
+}
diff --git a/src/Logging.AzureAppServices/src/BatchingLoggerProvider.cs b/src/Logging.AzureAppServices/src/BatchingLoggerProvider.cs
new file mode 100644
index 0000000000..227a616f3b
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/BatchingLoggerProvider.cs
@@ -0,0 +1,208 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices
+{
+ ///
+ /// A provider of instances.
+ ///
+ public abstract class BatchingLoggerProvider : ILoggerProvider, ISupportExternalScope
+ {
+ private readonly List _currentBatch = new List();
+ private readonly TimeSpan _interval;
+ private readonly int? _queueSize;
+ private readonly int? _batchSize;
+ private readonly IDisposable _optionsChangeToken;
+
+ private int _messagesDropped;
+
+ private BlockingCollection _messageQueue;
+ private Task _outputTask;
+ private CancellationTokenSource _cancellationTokenSource;
+
+ private bool _includeScopes;
+ private IExternalScopeProvider _scopeProvider;
+
+ internal IExternalScopeProvider ScopeProvider => _includeScopes ? _scopeProvider : null;
+
+ internal BatchingLoggerProvider(IOptionsMonitor options)
+ {
+ // NOTE: Only IsEnabled is monitored
+
+ var loggerOptions = options.CurrentValue;
+ if (loggerOptions.BatchSize <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(loggerOptions.BatchSize), $"{nameof(loggerOptions.BatchSize)} must be a positive number.");
+ }
+ if (loggerOptions.FlushPeriod <= TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(loggerOptions.FlushPeriod), $"{nameof(loggerOptions.FlushPeriod)} must be longer than zero.");
+ }
+
+ _interval = loggerOptions.FlushPeriod;
+ _batchSize = loggerOptions.BatchSize;
+ _queueSize = loggerOptions.BackgroundQueueSize;
+
+ _optionsChangeToken = options.OnChange(UpdateOptions);
+ UpdateOptions(options.CurrentValue);
+ }
+
+ ///
+ /// Checks if the queue is enabled.
+ ///
+ public bool IsEnabled { get; private set; }
+
+ private void UpdateOptions(BatchingLoggerOptions options)
+ {
+ var oldIsEnabled = IsEnabled;
+ IsEnabled = options.IsEnabled;
+ _includeScopes = options.IncludeScopes;
+
+ if (oldIsEnabled != IsEnabled)
+ {
+ if (IsEnabled)
+ {
+ Start();
+ }
+ else
+ {
+ Stop();
+ }
+ }
+
+ }
+
+ internal abstract Task WriteMessagesAsync(IEnumerable messages, CancellationToken token);
+
+ private async Task ProcessLogQueue()
+ {
+ while (!_cancellationTokenSource.IsCancellationRequested)
+ {
+ var limit = _batchSize ?? int.MaxValue;
+
+ while (limit > 0 && _messageQueue.TryTake(out var message))
+ {
+ _currentBatch.Add(message);
+ limit--;
+ }
+
+ var messagesDropped = Interlocked.Exchange(ref _messagesDropped, 0);
+ if (messagesDropped != 0)
+ {
+ _currentBatch.Add(new LogMessage(DateTimeOffset.Now, $"{messagesDropped} message(s) dropped because of queue size limit. Increase the queue size or decrease logging verbosity to avoid this.{Environment.NewLine}"));
+ }
+
+ if (_currentBatch.Count > 0)
+ {
+ try
+ {
+ await WriteMessagesAsync(_currentBatch, _cancellationTokenSource.Token);
+ }
+ catch
+ {
+ // ignored
+ }
+
+ _currentBatch.Clear();
+ }
+ else
+ {
+ await IntervalAsync(_interval, _cancellationTokenSource.Token);
+ }
+ }
+ }
+
+ ///
+ /// Wait for the given .
+ ///
+ /// The amount of time to wait.
+ /// A that can be used to cancel the delay.
+ /// A which completes when the has passed or the has been canceled.
+ protected virtual Task IntervalAsync(TimeSpan interval, CancellationToken cancellationToken)
+ {
+ return Task.Delay(interval, cancellationToken);
+ }
+
+ internal void AddMessage(DateTimeOffset timestamp, string message)
+ {
+ if (!_messageQueue.IsAddingCompleted)
+ {
+ try
+ {
+ if (!_messageQueue.TryAdd(new LogMessage(timestamp, message), millisecondsTimeout: 0, cancellationToken: _cancellationTokenSource.Token))
+ {
+ Interlocked.Increment(ref _messagesDropped);
+ }
+ }
+ catch
+ {
+ //cancellation token canceled or CompleteAdding called
+ }
+ }
+ }
+
+ private void Start()
+ {
+ _messageQueue = _queueSize == null ?
+ new BlockingCollection(new ConcurrentQueue()) :
+ new BlockingCollection(new ConcurrentQueue(), _queueSize.Value);
+
+ _cancellationTokenSource = new CancellationTokenSource();
+ _outputTask = Task.Run(ProcessLogQueue);
+ }
+
+ private void Stop()
+ {
+ _cancellationTokenSource.Cancel();
+ _messageQueue.CompleteAdding();
+
+ try
+ {
+ _outputTask.Wait(_interval);
+ }
+ catch (TaskCanceledException)
+ {
+ }
+ catch (AggregateException ex) when (ex.InnerExceptions.Count == 1 && ex.InnerExceptions[0] is TaskCanceledException)
+ {
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ _optionsChangeToken?.Dispose();
+ if (IsEnabled)
+ {
+ Stop();
+ }
+ }
+
+ ///
+ /// Creates a with the given .
+ ///
+ /// The name of the category to create this logger with.
+ /// The that was created.
+ public ILogger CreateLogger(string categoryName)
+ {
+ return new BatchingLogger(this, categoryName);
+ }
+
+ ///
+ /// Sets the scope on this provider.
+ ///
+ /// Provides the scope.
+ void ISupportExternalScope.SetScopeProvider(IExternalScopeProvider scopeProvider)
+ {
+ _scopeProvider = scopeProvider;
+ }
+ }
+}
diff --git a/src/Logging.AzureAppServices/src/BlobAppendReferenceWrapper.cs b/src/Logging.AzureAppServices/src/BlobAppendReferenceWrapper.cs
new file mode 100644
index 0000000000..e9805128b7
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/BlobAppendReferenceWrapper.cs
@@ -0,0 +1,97 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices
+{
+ ///
+ internal class BlobAppendReferenceWrapper : ICloudAppendBlob
+ {
+ private readonly Uri _fullUri;
+ private readonly HttpClient _client;
+ private readonly Uri _appendUri;
+
+ public BlobAppendReferenceWrapper(string containerUrl, string name, HttpClient client)
+ {
+ var uriBuilder = new UriBuilder(containerUrl);
+ uriBuilder.Path += "/" + name;
+ _fullUri = uriBuilder.Uri;
+
+ AppendBlockQuery(uriBuilder);
+ _appendUri = uriBuilder.Uri;
+ _client = client;
+ }
+
+ ///
+ public async Task AppendAsync(ArraySegment data, CancellationToken cancellationToken)
+ {
+ Task AppendDataAsync()
+ {
+ var message = new HttpRequestMessage(HttpMethod.Put, _appendUri)
+ {
+ Content = new ByteArrayContent(data.Array, data.Offset, data.Count)
+ };
+ AddCommonHeaders(message);
+
+ return _client.SendAsync(message, cancellationToken);
+ }
+
+ var response = await AppendDataAsync();
+
+ if (response.StatusCode == HttpStatusCode.NotFound)
+ {
+ // If no blob exists try creating it
+ var message = new HttpRequestMessage(HttpMethod.Put, _fullUri)
+ {
+ // Set Content-Length to 0 to create "Append Blob"
+ Content = new ByteArrayContent(Array.Empty()),
+ Headers =
+ {
+ { "If-None-Match", "*" }
+ }
+ };
+
+ AddCommonHeaders(message);
+
+ response = await _client.SendAsync(message, cancellationToken);
+
+ // If result is 2** or 412 try to append again
+ if (response.IsSuccessStatusCode ||
+ response.StatusCode == HttpStatusCode.PreconditionFailed)
+ {
+ // Retry sending data after blob creation
+ response = await AppendDataAsync();
+ }
+ }
+
+ response.EnsureSuccessStatusCode();
+ }
+
+ private static void AddCommonHeaders(HttpRequestMessage message)
+ {
+ message.Headers.Add("x-ms-blob-type", "AppendBlob");
+ message.Headers.Add("x-ms-version", "2016-05-31");
+ message.Headers.Date = DateTimeOffset.UtcNow;
+ }
+
+ private static void AppendBlockQuery(UriBuilder uriBuilder)
+ {
+ // See https://msdn.microsoft.com/en-us/library/system.uribuilder.query.aspx for:
+ // Note: Do not append a string directly to Query property.
+ // If the length of Query is greater than 1, retrieve the property value
+ // as a string, remove the leading question mark, append the new query string,
+ // and set the property with the combined string.
+ var queryToAppend = "comp=appendblock";
+ if (uriBuilder.Query != null && uriBuilder.Query.Length > 1)
+ uriBuilder.Query = uriBuilder.Query.Substring(1) + "&" + queryToAppend;
+ else
+ uriBuilder.Query = queryToAppend;
+ }
+ }
+}
diff --git a/src/Logging.AzureAppServices/src/BlobLoggerConfigureOptions.cs b/src/Logging.AzureAppServices/src/BlobLoggerConfigureOptions.cs
new file mode 100644
index 0000000000..f9a186872b
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/BlobLoggerConfigureOptions.cs
@@ -0,0 +1,30 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices
+{
+ internal class BlobLoggerConfigureOptions : BatchLoggerConfigureOptions, IConfigureOptions
+ {
+ private readonly IConfiguration _configuration;
+ private readonly IWebAppContext _context;
+
+ public BlobLoggerConfigureOptions(IConfiguration configuration, IWebAppContext context)
+ : base(configuration, "AzureBlobEnabled")
+ {
+ _configuration = configuration;
+ _context = context;
+ }
+
+ public void Configure(AzureBlobLoggerOptions options)
+ {
+ base.Configure(options);
+ options.ContainerUrl = _configuration.GetSection("APPSETTING_DIAGNOSTICS_AZUREBLOBCONTAINERSASURL")?.Value;
+ options.ApplicationName = _context.SiteName;
+ options.ApplicationInstanceId = _context.SiteInstanceId;
+ }
+ }
+}
diff --git a/src/Logging.AzureAppServices/src/BlobLoggerProvider.cs b/src/Logging.AzureAppServices/src/BlobLoggerProvider.cs
new file mode 100644
index 0000000000..3d62ea2ac6
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/BlobLoggerProvider.cs
@@ -0,0 +1,92 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices
+{
+ ///
+ /// The implementation that stores messages by appending them to Azure Blob in batches.
+ ///
+ [ProviderAlias("AzureAppServicesBlob")]
+ public class BlobLoggerProvider : BatchingLoggerProvider
+ {
+ private readonly string _appName;
+ private readonly string _fileName;
+ private readonly Func _blobReferenceFactory;
+ private readonly HttpClient _httpClient;
+
+ ///
+ /// Creates a new instance of
+ ///
+ /// The options to use when creating a provider.
+ public BlobLoggerProvider(IOptionsMonitor options)
+ : this(options, null)
+ {
+ _blobReferenceFactory = name => new BlobAppendReferenceWrapper(
+ options.CurrentValue.ContainerUrl,
+ name,
+ _httpClient);
+ }
+
+ ///
+ /// Creates a new instance of
+ ///
+ /// The container to store logs to.
+ /// Options to be used in creating a logger.
+ internal BlobLoggerProvider(
+ IOptionsMonitor options,
+ Func blobReferenceFactory) :
+ base(options)
+ {
+ var value = options.CurrentValue;
+ _appName = value.ApplicationName;
+ _fileName = value.ApplicationInstanceId + "_" + value.BlobName;
+ _blobReferenceFactory = blobReferenceFactory;
+ _httpClient = new HttpClient();
+ }
+
+ internal override async Task WriteMessagesAsync(IEnumerable messages, CancellationToken cancellationToken)
+ {
+ var eventGroups = messages.GroupBy(GetBlobKey);
+ foreach (var eventGroup in eventGroups)
+ {
+ var key = eventGroup.Key;
+ var blobName = $"{_appName}/{key.Year}/{key.Month:00}/{key.Day:00}/{key.Hour:00}/{_fileName}";
+
+ var blob = _blobReferenceFactory(blobName);
+
+ using (var stream = new MemoryStream())
+ using (var writer = new StreamWriter(stream))
+ {
+ foreach (var logEvent in eventGroup)
+ {
+ writer.Write(logEvent.Message);
+ }
+
+ await writer.FlushAsync();
+ var tryGetBuffer = stream.TryGetBuffer(out var buffer);
+ Debug.Assert(tryGetBuffer);
+ await blob.AppendAsync(buffer, cancellationToken);
+ }
+ }
+ }
+
+ private (int Year, int Month, int Day, int Hour) GetBlobKey(LogMessage e)
+ {
+ return (e.Timestamp.Year,
+ e.Timestamp.Month,
+ e.Timestamp.Day,
+ e.Timestamp.Hour);
+ }
+ }
+}
diff --git a/src/Logging.AzureAppServices/src/ConfigurationBasedLevelSwitcher.cs b/src/Logging.AzureAppServices/src/ConfigurationBasedLevelSwitcher.cs
new file mode 100644
index 0000000000..c62ccb2331
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/ConfigurationBasedLevelSwitcher.cs
@@ -0,0 +1,51 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices
+{
+ internal class ConfigurationBasedLevelSwitcher: IConfigureOptions
+ {
+ private readonly IConfiguration _configuration;
+ private readonly Type _provider;
+ private readonly string _levelKey;
+
+ public ConfigurationBasedLevelSwitcher(IConfiguration configuration, Type provider, string levelKey)
+ {
+ _configuration = configuration;
+ _provider = provider;
+ _levelKey = levelKey;
+ }
+
+ public void Configure(LoggerFilterOptions options)
+ {
+ options.Rules.Add(new LoggerFilterRule(_provider.FullName, null, GetLogLevel(), null));
+ }
+
+ private LogLevel GetLogLevel()
+ {
+ return TextToLogLevel(_configuration.GetSection(_levelKey)?.Value);
+ }
+
+ 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/Logging.AzureAppServices/src/FileLoggerConfigureOptions.cs b/src/Logging.AzureAppServices/src/FileLoggerConfigureOptions.cs
new file mode 100644
index 0000000000..8cd1f5eb91
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/FileLoggerConfigureOptions.cs
@@ -0,0 +1,27 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.IO;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices
+{
+ internal class FileLoggerConfigureOptions : BatchLoggerConfigureOptions, IConfigureOptions
+ {
+ private readonly IWebAppContext _context;
+
+ public FileLoggerConfigureOptions(IConfiguration configuration, IWebAppContext context)
+ : base(configuration, "AzureDriveEnabled")
+ {
+ _context = context;
+ }
+
+ public void Configure(AzureFileLoggerOptions options)
+ {
+ base.Configure(options);
+ options.LogDirectory = Path.Combine(_context.HomeFolder, "LogFiles", "Application");
+ }
+ }
+}
diff --git a/src/Logging.AzureAppServices/src/FileLoggerProvider.cs b/src/Logging.AzureAppServices/src/FileLoggerProvider.cs
new file mode 100644
index 0000000000..1143d38c07
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/FileLoggerProvider.cs
@@ -0,0 +1,89 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices
+{
+ ///
+ /// A which writes out to a file.
+ ///
+ [ProviderAlias("AzureAppServicesFile")]
+ public class FileLoggerProvider : BatchingLoggerProvider
+ {
+ private readonly string _path;
+ private readonly string _fileName;
+ private readonly int? _maxFileSize;
+ private readonly int? _maxRetainedFiles;
+
+ ///
+ /// Creates a new instance of .
+ ///
+ /// The options to use when creating a provider.
+ public FileLoggerProvider(IOptionsMonitor options) : base(options)
+ {
+ var loggerOptions = options.CurrentValue;
+ _path = loggerOptions.LogDirectory;
+ _fileName = loggerOptions.FileName;
+ _maxFileSize = loggerOptions.FileSizeLimit;
+ _maxRetainedFiles = loggerOptions.RetainedFileCountLimit;
+ }
+
+ internal override async Task WriteMessagesAsync(IEnumerable messages, CancellationToken cancellationToken)
+ {
+ Directory.CreateDirectory(_path);
+
+ foreach (var group in messages.GroupBy(GetGrouping))
+ {
+ var fullName = GetFullName(group.Key);
+ var fileInfo = new FileInfo(fullName);
+ if (_maxFileSize > 0 && fileInfo.Exists && fileInfo.Length > _maxFileSize)
+ {
+ return;
+ }
+
+ using (var streamWriter = File.AppendText(fullName))
+ {
+ foreach (var item in group)
+ {
+ await streamWriter.WriteAsync(item.Message);
+ }
+ }
+ }
+
+ RollFiles();
+ }
+
+ private string GetFullName((int Year, int Month, int Day) group)
+ {
+ return Path.Combine(_path, $"{_fileName}{group.Year:0000}{group.Month:00}{group.Day:00}.txt");
+ }
+
+ private (int Year, int Month, int Day) GetGrouping(LogMessage message)
+ {
+ return (message.Timestamp.Year, message.Timestamp.Month, message.Timestamp.Day);
+ }
+
+ private void RollFiles()
+ {
+ if (_maxRetainedFiles > 0)
+ {
+ var files = new DirectoryInfo(_path)
+ .GetFiles(_fileName + "*")
+ .OrderByDescending(f => f.Name)
+ .Skip(_maxRetainedFiles.Value);
+
+ foreach (var item in files)
+ {
+ item.Delete();
+ }
+ }
+ }
+ }
+}
diff --git a/src/Logging.AzureAppServices/src/ICloudAppendBlob.cs b/src/Logging.AzureAppServices/src/ICloudAppendBlob.cs
new file mode 100644
index 0000000000..2f55bbb0d1
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/ICloudAppendBlob.cs
@@ -0,0 +1,23 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices
+{
+ ///
+ /// Represents an append blob, a type of blob where blocks of data are always committed to the end of the blob.
+ ///
+ internal 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 AppendAsync(ArraySegment data, CancellationToken cancellationToken);
+ }
+}
diff --git a/src/Logging.AzureAppServices/src/IWebAppContext.cs b/src/Logging.AzureAppServices/src/IWebAppContext.cs
new file mode 100644
index 0000000000..f8c826ceb8
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/IWebAppContext.cs
@@ -0,0 +1,32 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace Microsoft.Extensions.Logging.AzureAppServices
+{
+ ///
+ /// Represents an Azure WebApp context
+ ///
+ internal interface IWebAppContext
+ {
+ ///
+ /// Gets the path to the home folder if running in Azure WebApp
+ ///
+ 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
+ ///
+ bool IsRunningInAzureWebApp { get; }
+ }
+}
diff --git a/src/Logging.AzureAppServices/src/LogMessage.cs b/src/Logging.AzureAppServices/src/LogMessage.cs
new file mode 100644
index 0000000000..4a1179ceb3
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/LogMessage.cs
@@ -0,0 +1,20 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices
+{
+ internal readonly struct LogMessage
+ {
+ public LogMessage(DateTimeOffset timestamp, string message)
+ {
+ Timestamp = timestamp;
+ Message = message;
+ }
+
+ public DateTimeOffset Timestamp { get; }
+ public string Message { get; }
+ }
+}
diff --git a/src/Logging.AzureAppServices/src/Microsoft.Extensions.Logging.AzureAppServices.csproj b/src/Logging.AzureAppServices/src/Microsoft.Extensions.Logging.AzureAppServices.csproj
new file mode 100644
index 0000000000..5bedde8c6d
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/Microsoft.Extensions.Logging.AzureAppServices.csproj
@@ -0,0 +1,24 @@
+
+
+
+ Logger implementation to support Azure App Services 'Diagnostics logs' and 'Log stream' features.
+ netstandard2.0
+ $(NoWarn);CS1591
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Logging.AzureAppServices/src/Properties/AssemblyInfo.cs b/src/Logging.AzureAppServices/src/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000000..7c7d332545
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/Properties/AssemblyInfo.cs
@@ -0,0 +1,8 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Runtime.CompilerServices;
+
+
+[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")]
diff --git a/src/Logging.AzureAppServices/src/SiteConfigurationProvider.cs b/src/Logging.AzureAppServices/src/SiteConfigurationProvider.cs
new file mode 100644
index 0000000000..452c936f93
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/SiteConfigurationProvider.cs
@@ -0,0 +1,23 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.IO;
+using Microsoft.Extensions.Configuration;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices
+{
+ internal class SiteConfigurationProvider
+ {
+ public static IConfiguration GetAzureLoggingConfiguration(IWebAppContext context)
+ {
+ var settingsFolder = Path.Combine(context.HomeFolder, "site", "diagnostics");
+ var settingsFile = Path.Combine(settingsFolder, "settings.json");
+
+ return new ConfigurationBuilder()
+ .AddEnvironmentVariables()
+ .AddJsonFile(settingsFile, optional: true, reloadOnChange: true)
+ .Build();
+ }
+ }
+}
diff --git a/src/Logging.AzureAppServices/src/WebAppContext.cs b/src/Logging.AzureAppServices/src/WebAppContext.cs
new file mode 100644
index 0000000000..8bdd3f1c76
--- /dev/null
+++ b/src/Logging.AzureAppServices/src/WebAppContext.cs
@@ -0,0 +1,34 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices
+{
+ ///
+ /// Represents the default implementation of .
+ ///
+ internal 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 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/Logging.AzureAppServices/test/AzureAppendBlobTests.cs b/src/Logging.AzureAppServices/test/AzureAppendBlobTests.cs
new file mode 100644
index 0000000000..2fd5955e86
--- /dev/null
+++ b/src/Logging.AzureAppServices/test/AzureAppendBlobTests.cs
@@ -0,0 +1,186 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices.Test
+{
+ public class AzureAppendBlobTests
+ {
+ public string _containerUrl = "https://host/container?query=1";
+ public string _blobName = "blob/path";
+
+ [Fact]
+ public async Task SendsDataAsStream()
+ {
+ var testMessageHandler = new TestMessageHandler(async message =>
+ {
+ Assert.Equal(HttpMethod.Put, message.Method);
+ Assert.Equal("https://host/container/blob/path?query=1&comp=appendblock", message.RequestUri.ToString());
+ Assert.Equal(new byte[] { 0, 2, 3 }, await message.Content.ReadAsByteArrayAsync());
+ AssertDefaultHeaders(message);
+
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ });
+
+ var blob = new BlobAppendReferenceWrapper(_containerUrl, _blobName, new HttpClient(testMessageHandler));
+ await blob.AppendAsync(new ArraySegment(new byte[] { 0, 2, 3 }), CancellationToken.None);
+ }
+
+ private static void AssertDefaultHeaders(HttpRequestMessage message)
+ {
+ Assert.Equal(new[] {"AppendBlob"}, message.Headers.GetValues("x-ms-blob-type"));
+ Assert.Equal(new[] {"2016-05-31"}, message.Headers.GetValues("x-ms-version"));
+ Assert.NotNull(message.Headers.Date);
+ }
+
+ [Theory]
+ [InlineData(HttpStatusCode.Created)]
+ [InlineData(HttpStatusCode.PreconditionFailed)]
+ public async Task CreatesBlobIfNotExist(HttpStatusCode createStatusCode)
+ {
+ var stage = 0;
+ var testMessageHandler = new TestMessageHandler(async message =>
+ {
+ // First PUT request
+ if (stage == 0)
+ {
+ Assert.Equal(HttpMethod.Put, message.Method);
+ Assert.Equal("https://host/container/blob/path?query=1&comp=appendblock", message.RequestUri.ToString());
+ Assert.Equal(new byte[] { 0, 2, 3 }, await message.Content.ReadAsByteArrayAsync());
+ Assert.Equal(3, message.Content.Headers.ContentLength);
+
+ AssertDefaultHeaders(message);
+
+ stage++;
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ }
+ // Create request
+ if (stage == 1)
+ {
+ Assert.Equal(HttpMethod.Put, message.Method);
+ Assert.Equal("https://host/container/blob/path?query=1", message.RequestUri.ToString());
+ Assert.Equal(0, message.Content.Headers.ContentLength);
+ Assert.Equal(new[] { "*" }, message.Headers.GetValues("If-None-Match"));
+
+ AssertDefaultHeaders(message);
+
+ stage++;
+ return new HttpResponseMessage(createStatusCode);
+ }
+ // First PUT request
+ if (stage == 2)
+ {
+ Assert.Equal(HttpMethod.Put, message.Method);
+ Assert.Equal("https://host/container/blob/path?query=1&comp=appendblock", message.RequestUri.ToString());
+ Assert.Equal(new byte[] { 0, 2, 3 }, await message.Content.ReadAsByteArrayAsync());
+ Assert.Equal(3, message.Content.Headers.ContentLength);
+
+ AssertDefaultHeaders(message);
+
+ stage++;
+ return new HttpResponseMessage(HttpStatusCode.Created);
+ }
+ throw new NotImplementedException();
+ });
+
+ var blob = new BlobAppendReferenceWrapper(_containerUrl, _blobName, new HttpClient(testMessageHandler));
+ await blob.AppendAsync(new ArraySegment(new byte[] { 0, 2, 3 }), CancellationToken.None);
+
+ Assert.Equal(3, stage);
+ }
+
+ [Fact]
+ public async Task ThrowsForUnknownStatus()
+ {
+ var stage = 0;
+ var testMessageHandler = new TestMessageHandler(async message =>
+ {
+ // First PUT request
+ if (stage == 0)
+ {
+ Assert.Equal(HttpMethod.Put, message.Method);
+ Assert.Equal("https://host/container/blob/path?query=1&comp=appendblock", message.RequestUri.ToString());
+ Assert.Equal(new byte[] { 0, 2, 3 }, await message.Content.ReadAsByteArrayAsync());
+ Assert.Equal(3, message.Content.Headers.ContentLength);
+
+ AssertDefaultHeaders(message);
+
+ stage++;
+ return new HttpResponseMessage(HttpStatusCode.InternalServerError);
+ }
+
+ throw new NotImplementedException();
+ });
+
+ var blob = new BlobAppendReferenceWrapper(_containerUrl, _blobName, new HttpClient(testMessageHandler));
+ await Assert.ThrowsAsync(() => blob.AppendAsync(new ArraySegment(new byte[] { 0, 2, 3 }), CancellationToken.None));
+
+ Assert.Equal(1, stage);
+ }
+
+ [Fact]
+ public async Task ThrowsForUnknownStatusDuringCreation()
+ {
+ var stage = 0;
+ var testMessageHandler = new TestMessageHandler(async message =>
+ {
+ // First PUT request
+ if (stage == 0)
+ {
+ Assert.Equal(HttpMethod.Put, message.Method);
+ Assert.Equal("https://host/container/blob/path?query=1&comp=appendblock", message.RequestUri.ToString());
+ Assert.Equal(new byte[] { 0, 2, 3 }, await message.Content.ReadAsByteArrayAsync());
+ Assert.Equal(3, message.Content.Headers.ContentLength);
+
+ AssertDefaultHeaders(message);
+
+ stage++;
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ }
+ // Create request
+ if (stage == 1)
+ {
+ Assert.Equal(HttpMethod.Put, message.Method);
+ Assert.Equal("https://host/container/blob/path?query=1", message.RequestUri.ToString());
+ Assert.Equal(0, message.Content.Headers.ContentLength);
+ Assert.Equal(new[] { "*" }, message.Headers.GetValues("If-None-Match"));
+
+ AssertDefaultHeaders(message);
+
+ stage++;
+ return new HttpResponseMessage(HttpStatusCode.InternalServerError);
+ }
+
+ throw new NotImplementedException();
+ });
+
+ var blob = new BlobAppendReferenceWrapper(_containerUrl, _blobName, new HttpClient(testMessageHandler));
+ await Assert.ThrowsAsync(() => blob.AppendAsync(new ArraySegment(new byte[] { 0, 2, 3 }), CancellationToken.None));
+
+ Assert.Equal(2, stage);
+ }
+
+
+ private class TestMessageHandler : HttpMessageHandler
+ {
+ private readonly Func> _callback;
+
+ public TestMessageHandler(Func> callback)
+ {
+ _callback = callback;
+ }
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ return await _callback(request);
+ }
+ }
+ }
+}
diff --git a/src/Logging.AzureAppServices/test/AzureBlobSinkTests.cs b/src/Logging.AzureAppServices/test/AzureBlobSinkTests.cs
new file mode 100644
index 0000000000..4d9125335a
--- /dev/null
+++ b/src/Logging.AzureAppServices/test/AzureBlobSinkTests.cs
@@ -0,0 +1,98 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Moq;
+using Xunit;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices.Test
+{
+ public class AzureBlobSinkTests
+ {
+ DateTimeOffset _timestampOne = new DateTimeOffset(2016, 05, 04, 03, 02, 01, TimeSpan.Zero);
+
+ [Fact]
+ public async Task WritesMessagesInBatches()
+ {
+ var blob = new Mock();
+ var buffers = new List();
+ blob.Setup(b => b.AppendAsync(It.IsAny>(), It.IsAny()))
+ .Callback((ArraySegment s, CancellationToken ct) => buffers.Add(ToArray(s)))
+ .Returns(Task.CompletedTask);
+
+ var sink = new TestBlobSink(name => blob.Object);
+ var logger = (BatchingLogger)sink.CreateLogger("Cat");
+
+ await sink.IntervalControl.Pause;
+
+ for (int i = 0; i < 5; i++)
+ {
+ logger.Log(_timestampOne, LogLevel.Information, 0, "Text " + i, null, (state, ex) => state);
+ }
+
+ sink.IntervalControl.Resume();
+ await sink.IntervalControl.Pause;
+
+ Assert.Single(buffers);
+ Assert.Equal(
+ "2016-05-04 03:02:01.000 +00:00 [Information] Cat: Text 0" + Environment.NewLine +
+ "2016-05-04 03:02:01.000 +00:00 [Information] Cat: Text 1" + Environment.NewLine +
+ "2016-05-04 03:02:01.000 +00:00 [Information] Cat: Text 2" + Environment.NewLine +
+ "2016-05-04 03:02:01.000 +00:00 [Information] Cat: Text 3" + Environment.NewLine +
+ "2016-05-04 03:02:01.000 +00:00 [Information] Cat: Text 4" + Environment.NewLine,
+ Encoding.UTF8.GetString(buffers[0]));
+ }
+
+ [Fact]
+ public async Task GroupsByHour()
+ {
+ var blob = new Mock();
+ var buffers = new List();
+ var names = new List();
+
+ blob.Setup(b => b.AppendAsync(It.IsAny>(), It.IsAny()))
+ .Callback((ArraySegment s, CancellationToken ct) => buffers.Add(ToArray(s)))
+ .Returns(Task.CompletedTask);
+
+ var sink = new TestBlobSink(name =>
+ {
+ names.Add(name);
+ return blob.Object;
+ });
+ var logger = (BatchingLogger)sink.CreateLogger("Cat");
+
+ await sink.IntervalControl.Pause;
+
+ var startDate = _timestampOne;
+ for (int i = 0; i < 3; i++)
+ {
+ logger.Log(startDate, LogLevel.Information, 0, "Text " + i, null, (state, ex) => state);
+
+ startDate = startDate.AddHours(1);
+ }
+
+ sink.IntervalControl.Resume();
+ await sink.IntervalControl.Pause;
+
+ Assert.Equal(3, buffers.Count);
+
+ Assert.Equal("appname/2016/05/04/03/42_filename", names[0]);
+ Assert.Equal("appname/2016/05/04/04/42_filename", names[1]);
+ Assert.Equal("appname/2016/05/04/05/42_filename", names[2]);
+ }
+
+ private byte[] ToArray(ArraySegment inputStream)
+ {
+ return inputStream.Array
+ .Skip(inputStream.Offset)
+ .Take(inputStream.Count)
+ .ToArray();
+ }
+ }
+}
diff --git a/src/Logging.AzureAppServices/test/AzureDiagnosticsConfigurationProviderTests.cs b/src/Logging.AzureAppServices/test/AzureDiagnosticsConfigurationProviderTests.cs
new file mode 100644
index 0000000000..00d7dcd58d
--- /dev/null
+++ b/src/Logging.AzureAppServices/test/AzureDiagnosticsConfigurationProviderTests.cs
@@ -0,0 +1,70 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.IO;
+using Moq;
+using Xunit;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices.Test
+{
+ public class AzureDiagnosticsConfigurationProviderTests
+ {
+ [Fact]
+ public void NoConfigFile()
+ {
+ var tempFolder = Path.Combine(Path.GetTempPath(), "AzureWebAppLoggerThisFolderShouldNotExist");
+
+ var contextMock = new Mock();
+ contextMock.SetupGet(c => c.HomeFolder)
+ .Returns(tempFolder);
+
+ var config = SiteConfigurationProvider.GetAzureLoggingConfiguration(contextMock.Object);
+
+ Assert.NotNull(config);
+ }
+
+ [Fact]
+ public void ReadsSettingsFileAndEnvironment()
+ {
+ var tempFolder = Path.Combine(Path.GetTempPath(), "WebAppLoggerConfigurationDisabledInSettingsFile");
+
+ try
+ {
+ var settingsFolder = Path.Combine(tempFolder, "site", "diagnostics");
+ var settingsFile = Path.Combine(settingsFolder, "settings.json");
+
+ if (!Directory.Exists(settingsFolder))
+ {
+ Directory.CreateDirectory(settingsFolder);
+ }
+ Environment.SetEnvironmentVariable("RANDOM_ENVIRONMENT_VARIABLE", "USEFUL_VALUE");
+ File.WriteAllText(settingsFile, @"{ ""key"":""test value"" }");
+
+ var contextMock = new Mock();
+ contextMock.SetupGet(c => c.HomeFolder)
+ .Returns(tempFolder);
+
+ var config = SiteConfigurationProvider.GetAzureLoggingConfiguration(contextMock.Object);
+
+ Assert.Equal("test value", config["key"]);
+ Assert.Equal("USEFUL_VALUE", config["RANDOM_ENVIRONMENT_VARIABLE"]);
+ }
+ finally
+ {
+ if (Directory.Exists(tempFolder))
+ {
+ try
+ {
+ Directory.Delete(tempFolder, recursive: true);
+ }
+ catch
+ {
+ // Don't break the test if temp folder deletion fails.
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/Logging.AzureAppServices/test/BatchingLoggerProviderTests.cs b/src/Logging.AzureAppServices/test/BatchingLoggerProviderTests.cs
new file mode 100644
index 0000000000..9ab0c0cb45
--- /dev/null
+++ b/src/Logging.AzureAppServices/test/BatchingLoggerProviderTests.cs
@@ -0,0 +1,136 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices.Test
+{
+ public class BatchingLoggerProviderTests
+ {
+ private DateTimeOffset _timestampOne = new DateTimeOffset(2016, 05, 04, 03, 02, 01, TimeSpan.Zero);
+ private string _nl = Environment.NewLine;
+ private Regex _timeStampRegex = new Regex(@"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3} .\d{2}:\d{2} ");
+
+ [Fact]
+ public async Task LogsInIntervals()
+ {
+ var provider = new TestBatchingLoggingProvider();
+ var logger = (BatchingLogger)provider.CreateLogger("Cat");
+
+ await provider.IntervalControl.Pause;
+
+ logger.Log(_timestampOne, LogLevel.Information, 0, "Info message", null, (state, ex) => state);
+ logger.Log(_timestampOne.AddHours(1), LogLevel.Error, 0, "Error message", null, (state, ex) => state);
+
+ provider.IntervalControl.Resume();
+ await provider.IntervalControl.Pause;
+
+ Assert.Equal("2016-05-04 03:02:01.000 +00:00 [Information] Cat: Info message" + _nl, provider.Batches[0][0].Message);
+ Assert.Equal("2016-05-04 04:02:01.000 +00:00 [Error] Cat: Error message" + _nl, provider.Batches[0][1].Message);
+ }
+
+ [Fact]
+ public async Task IncludesScopes()
+ {
+ var provider = new TestBatchingLoggingProvider(includeScopes: true);
+ var factory = new LoggerFactory(new [] { provider });
+ var logger = factory.CreateLogger("Cat");
+
+ await provider.IntervalControl.Pause;
+
+ using (logger.BeginScope("Scope"))
+ {
+ using (logger.BeginScope("Scope2"))
+ {
+ logger.Log(LogLevel.Information, 0, "Info message", null, (state, ex) => state);
+ }
+ }
+
+ provider.IntervalControl.Resume();
+ await provider.IntervalControl.Pause;
+
+ Assert.Matches(_timeStampRegex, provider.Batches[0][0].Message);
+ Assert.EndsWith(
+ " [Information] Cat => Scope => Scope2:" + _nl +
+ "Info message" + _nl,
+ provider.Batches[0][0].Message);
+ }
+
+ [Fact]
+ public async Task RespectsBatchSize()
+ {
+ var provider = new TestBatchingLoggingProvider(maxBatchSize: 1);
+ var logger = (BatchingLogger)provider.CreateLogger("Cat");
+
+ await provider.IntervalControl.Pause;
+
+ logger.Log(_timestampOne, LogLevel.Information, 0, "Info message", null, (state, ex) => state);
+ logger.Log(_timestampOne.AddHours(1), LogLevel.Error, 0, "Error message", null, (state, ex) => state);
+
+ provider.IntervalControl.Resume();
+ await provider.IntervalControl.Pause;
+
+ Assert.Equal(2, provider.Batches.Count);
+ Assert.Single(provider.Batches[0]);
+ Assert.Equal("2016-05-04 03:02:01.000 +00:00 [Information] Cat: Info message" + _nl, provider.Batches[0][0].Message);
+
+ Assert.Single(provider.Batches[1]);
+ Assert.Equal("2016-05-04 04:02:01.000 +00:00 [Error] Cat: Error message" + _nl, provider.Batches[1][0].Message);
+ }
+
+ [Fact]
+ public async Task DropsMessagesWhenReachingMaxQueue()
+ {
+ var provider = new TestBatchingLoggingProvider(maxQueueSize: 1);
+ var logger = (BatchingLogger)provider.CreateLogger("Cat");
+
+ await provider.IntervalControl.Pause;
+
+ logger.Log(_timestampOne, LogLevel.Information, 0, "Info message", null, (state, ex) => state);
+ logger.Log(_timestampOne.AddHours(1), LogLevel.Error, 0, "Error message", null, (state, ex) => state);
+
+ provider.IntervalControl.Resume();
+ await provider.IntervalControl.Pause;
+
+ Assert.Equal(2, provider.Batches[0].Length);
+ Assert.Equal("2016-05-04 03:02:01.000 +00:00 [Information] Cat: Info message" + _nl, provider.Batches[0][0].Message);
+ Assert.Equal("1 message(s) dropped because of queue size limit. Increase the queue size or decrease logging verbosity to avoid this." + _nl, provider.Batches[0][1].Message);
+ }
+
+ private class TestBatchingLoggingProvider: BatchingLoggerProvider
+ {
+ public List Batches { get; } = new List();
+ public ManualIntervalControl IntervalControl { get; } = new ManualIntervalControl();
+
+ public TestBatchingLoggingProvider(TimeSpan? interval = null, int? maxBatchSize = null, int? maxQueueSize = null, bool includeScopes = false)
+ : base(new OptionsWrapperMonitor(new BatchingLoggerOptions
+ {
+ FlushPeriod = interval ?? TimeSpan.FromSeconds(1),
+ BatchSize = maxBatchSize,
+ BackgroundQueueSize = maxQueueSize,
+ IsEnabled = true,
+ IncludeScopes = includeScopes
+ }))
+ {
+ }
+
+ internal override Task WriteMessagesAsync(IEnumerable messages, CancellationToken token)
+ {
+ Batches.Add(messages.ToArray());
+ return Task.CompletedTask;
+ }
+
+ protected override Task IntervalAsync(TimeSpan interval, CancellationToken cancellationToken)
+ {
+ return IntervalControl.IntervalAsync();
+ }
+ }
+ }
+}
diff --git a/src/Logging.AzureAppServices/test/ConfigureOptionsTests.cs b/src/Logging.AzureAppServices/test/ConfigureOptionsTests.cs
new file mode 100644
index 0000000000..46b72c7a0d
--- /dev/null
+++ b/src/Logging.AzureAppServices/test/ConfigureOptionsTests.cs
@@ -0,0 +1,71 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Microsoft.Extensions.Configuration;
+using Moq;
+using Xunit;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices.Test
+{
+ public class ConfigureOptionsTests
+ {
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ [InlineData(null)]
+ public void InitializesIsEnabled(bool? enabled)
+ {
+ var configuration = new ConfigurationBuilder().AddInMemoryCollection(new[]
+ {
+ new KeyValuePair("IsEnabledKey", Convert.ToString(enabled))
+ }).Build();
+
+ var options = new BatchingLoggerOptions();
+ new BatchLoggerConfigureOptions(configuration, "IsEnabledKey").Configure(options);
+
+ Assert.Equal(enabled ?? false, options.IsEnabled);
+ }
+
+ [Fact]
+ public void InitializesLogDirectory()
+ {
+ var configuration = new ConfigurationBuilder().AddInMemoryCollection(new[]
+ {
+ new KeyValuePair("APPSETTING_DIAGNOSTICS_AZUREBLOBCONTAINERSASURL", "http://container/url")
+ }).Build();
+
+ var contextMock = new Mock();
+ contextMock.SetupGet(c => c.HomeFolder).Returns("Home");
+
+ var options = new AzureFileLoggerOptions();
+ new FileLoggerConfigureOptions(configuration, contextMock.Object).Configure(options);
+
+ Assert.Equal(Path.Combine("Home", "LogFiles", "Application"), options.LogDirectory);
+ }
+
+ [Fact]
+ public void InitializesBlobUriSiteInstanceAndName()
+ {
+ var configuration = new ConfigurationBuilder().AddInMemoryCollection(new []
+ {
+ new KeyValuePair("APPSETTING_DIAGNOSTICS_AZUREBLOBCONTAINERSASURL", "http://container/url")
+ }).Build();
+
+ var contextMock = new Mock();
+ contextMock.SetupGet(c => c.HomeFolder).Returns("Home");
+ contextMock.SetupGet(c => c.SiteInstanceId).Returns("InstanceId");
+ contextMock.SetupGet(c => c.SiteName).Returns("Name");
+
+ var options = new AzureBlobLoggerOptions();
+ new BlobLoggerConfigureOptions(configuration, contextMock.Object).Configure(options);
+
+ Assert.Equal("http://container/url", options.ContainerUrl);
+ Assert.Equal("InstanceId", options.ApplicationInstanceId);
+ Assert.Equal("Name", options.ApplicationName);
+ }
+ }
+}
diff --git a/src/Logging.AzureAppServices/test/FileLoggerTests.cs b/src/Logging.AzureAppServices/test/FileLoggerTests.cs
new file mode 100644
index 0000000000..a3fcd2587d
--- /dev/null
+++ b/src/Logging.AzureAppServices/test/FileLoggerTests.cs
@@ -0,0 +1,122 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices.Test
+{
+ public class FileLoggerTests: IDisposable
+ {
+ DateTimeOffset _timestampOne = new DateTimeOffset(2016, 05, 04, 03, 02, 01, TimeSpan.Zero);
+
+ public FileLoggerTests()
+ {
+ TempPath = Path.GetTempFileName() + "_";
+ }
+
+ public string TempPath { get; }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(TempPath))
+ {
+ Directory.Delete(TempPath, true);
+ }
+ }
+ catch
+ {
+ // ignored
+ }
+ }
+
+ [Fact]
+ public async Task WritesToTextFile()
+ {
+ var provider = new TestFileLoggerProvider(TempPath);
+ var logger = (BatchingLogger)provider.CreateLogger("Cat");
+
+ await provider.IntervalControl.Pause;
+
+ logger.Log(_timestampOne, LogLevel.Information, 0, "Info message", null, (state, ex) => state);
+ logger.Log(_timestampOne.AddHours(1), LogLevel.Error, 0, "Error message", null, (state, ex) => state);
+
+ provider.IntervalControl.Resume();
+ await provider.IntervalControl.Pause;
+
+ Assert.Equal(
+ "2016-05-04 03:02:01.000 +00:00 [Information] Cat: Info message" + Environment.NewLine +
+ "2016-05-04 04:02:01.000 +00:00 [Error] Cat: Error message" + Environment.NewLine,
+ File.ReadAllText(Path.Combine(TempPath, "LogFile.20160504.txt")));
+ }
+
+ [Fact]
+ public async Task RollsTextFile()
+ {
+ var provider = new TestFileLoggerProvider(TempPath);
+ var logger = (BatchingLogger)provider.CreateLogger("Cat");
+
+ await provider.IntervalControl.Pause;
+
+ logger.Log(_timestampOne, LogLevel.Information, 0, "Info message", null, (state, ex) => state);
+ logger.Log(_timestampOne.AddDays(1), LogLevel.Error, 0, "Error message", null, (state, ex) => state);
+
+ provider.IntervalControl.Resume();
+ await provider.IntervalControl.Pause;
+
+ Assert.Equal(
+ "2016-05-04 03:02:01.000 +00:00 [Information] Cat: Info message" + Environment.NewLine,
+ File.ReadAllText(Path.Combine(TempPath, "LogFile.20160504.txt")));
+
+ Assert.Equal(
+ "2016-05-05 03:02:01.000 +00:00 [Error] Cat: Error message" + Environment.NewLine,
+ File.ReadAllText(Path.Combine(TempPath, "LogFile.20160505.txt")));
+ }
+
+ [Fact]
+ public async Task RespectsMaxFileCount()
+ {
+ Directory.CreateDirectory(TempPath);
+ File.WriteAllText(Path.Combine(TempPath, "randomFile.txt"), "Text");
+
+ var provider = new TestFileLoggerProvider(TempPath, maxRetainedFiles: 5);
+ var logger = (BatchingLogger)provider.CreateLogger("Cat");
+
+ await provider.IntervalControl.Pause;
+ var timestamp = _timestampOne;
+
+ for (int i = 0; i < 10; i++)
+ {
+ logger.Log(timestamp, LogLevel.Information, 0, "Info message", null, (state, ex) => state);
+ logger.Log(timestamp.AddHours(1), LogLevel.Error, 0, "Error message", null, (state, ex) => state);
+
+ timestamp = timestamp.AddDays(1);
+ }
+
+ provider.IntervalControl.Resume();
+ await provider.IntervalControl.Pause;
+
+ var actualFiles = new DirectoryInfo(TempPath)
+ .GetFiles()
+ .Select(f => f.Name)
+ .OrderBy(f => f)
+ .ToArray();
+
+ Assert.Equal(6, actualFiles.Length);
+ Assert.Equal(new[] {
+ "LogFile.20160509.txt",
+ "LogFile.20160510.txt",
+ "LogFile.20160511.txt",
+ "LogFile.20160512.txt",
+ "LogFile.20160513.txt",
+ "randomFile.txt"
+ }, actualFiles);
+ }
+ }
+}
diff --git a/src/Logging.AzureAppServices/test/LoggerBuilderExtensionsTests.cs b/src/Logging.AzureAppServices/test/LoggerBuilderExtensionsTests.cs
new file mode 100644
index 0000000000..cf8bede1a5
--- /dev/null
+++ b/src/Logging.AzureAppServices/test/LoggerBuilderExtensionsTests.cs
@@ -0,0 +1,79 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Linq;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Moq;
+using Xunit;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices.Test
+{
+ public class LoggerBuilderExtensionsTests
+ {
+ private IWebAppContext _appContext;
+
+ public LoggerBuilderExtensionsTests()
+ {
+ var contextMock = new Mock();
+ contextMock.SetupGet(c => c.IsRunningInAzureWebApp).Returns(true);
+ contextMock.SetupGet(c => c.HomeFolder).Returns(".");
+ _appContext = contextMock.Object;
+ }
+
+ [Fact]
+ public void BuilderExtensionAddsSingleSetOfServicesWhenCalledTwice()
+ {
+ var serviceCollection = new ServiceCollection();
+ serviceCollection.AddLogging(builder => builder.AddAzureWebAppDiagnostics(_appContext));
+ var count = serviceCollection.Count;
+
+ Assert.NotEqual(0, count);
+
+ serviceCollection.AddLogging(builder => builder.AddAzureWebAppDiagnostics(_appContext));
+
+ Assert.Equal(count, serviceCollection.Count);
+ }
+
+ [Fact]
+ public void BuilderExtensionAddsConfigurationChangeTokenSource()
+ {
+ var serviceCollection = new ServiceCollection();
+ serviceCollection.AddLogging(builder => builder.AddConfiguration(new ConfigurationBuilder().Build()));
+
+ // Tracking for main configuration
+ Assert.Equal(1, serviceCollection.Count(d => d.ServiceType == typeof(IOptionsChangeTokenSource)));
+
+ serviceCollection.AddLogging(builder => builder.AddAzureWebAppDiagnostics(_appContext));
+
+ // Make sure we add another config change token for azure diagnostic configuration
+ Assert.Equal(2, serviceCollection.Count(d => d.ServiceType == typeof(IOptionsChangeTokenSource)));
+ }
+
+ [Fact]
+ public void BuilderExtensionAddsIConfigureOptions()
+ {
+ var serviceCollection = new ServiceCollection();
+ serviceCollection.AddLogging(builder => builder.AddConfiguration(new ConfigurationBuilder().Build()));
+
+ // Tracking for main configuration
+ Assert.Equal(2, serviceCollection.Count(d => d.ServiceType == typeof(IConfigureOptions)));
+
+ serviceCollection.AddLogging(builder => builder.AddAzureWebAppDiagnostics(_appContext));
+
+ Assert.Equal(4, serviceCollection.Count(d => d.ServiceType == typeof(IConfigureOptions)));
+ }
+
+ [Fact]
+ public void LoggerProviderIsResolvable()
+ {
+ var serviceCollection = new ServiceCollection();
+ serviceCollection.AddLogging(builder => builder.AddAzureWebAppDiagnostics(_appContext));
+
+ var serviceProvider = serviceCollection.BuildServiceProvider();
+ var loggerFactory = serviceProvider.GetService();
+ }
+ }
+}
diff --git a/src/Logging.AzureAppServices/test/ManualIntervalControl.cs b/src/Logging.AzureAppServices/test/ManualIntervalControl.cs
new file mode 100644
index 0000000000..29cc883a28
--- /dev/null
+++ b/src/Logging.AzureAppServices/test/ManualIntervalControl.cs
@@ -0,0 +1,31 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Threading.Tasks;
+
+namespace Microsoft.Extensions.Logging.AzureAppServices.Test
+{
+ internal class ManualIntervalControl
+ {
+
+ private TaskCompletionSource
diff --git a/src/SignalR/common/testassets/Tests.Utils/Microsoft.AspNetCore.SignalR.Tests.Utils.csproj b/src/SignalR/common/testassets/Tests.Utils/Microsoft.AspNetCore.SignalR.Tests.Utils.csproj
index 9e69675720..782c8410b8 100644
--- a/src/SignalR/common/testassets/Tests.Utils/Microsoft.AspNetCore.SignalR.Tests.Utils.csproj
+++ b/src/SignalR/common/testassets/Tests.Utils/Microsoft.AspNetCore.SignalR.Tests.Utils.csproj
@@ -21,7 +21,6 @@
-
diff --git a/src/Testing/src/Logging/BeginScopeContext.cs b/src/Testing/src/Logging/BeginScopeContext.cs
new file mode 100644
index 0000000000..14ef991e0d
--- /dev/null
+++ b/src/Testing/src/Logging/BeginScopeContext.cs
@@ -0,0 +1,13 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace Microsoft.Extensions.Logging.Testing
+{
+ public class BeginScopeContext
+ {
+ public object Scope { get; set; }
+
+ public string LoggerName { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/Testing/src/Logging/ITestSink.cs b/src/Testing/src/Logging/ITestSink.cs
new file mode 100644
index 0000000000..b328e5c595
--- /dev/null
+++ b/src/Testing/src/Logging/ITestSink.cs
@@ -0,0 +1,28 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Concurrent;
+
+namespace Microsoft.Extensions.Logging.Testing
+{
+ public interface ITestSink
+ {
+ event Action MessageLogged;
+
+ event Action ScopeStarted;
+
+ Func WriteEnabled { get; set; }
+
+ Func BeginEnabled { get; set; }
+
+ IProducerConsumerCollection Scopes { get; set; }
+
+ IProducerConsumerCollection Writes { get; set; }
+
+ void Write(WriteContext context);
+
+ void Begin(BeginScopeContext context);
+ }
+}
diff --git a/src/Testing/src/Logging/LogLevelAttribute.cs b/src/Testing/src/Logging/LogLevelAttribute.cs
new file mode 100644
index 0000000000..74aa395d4b
--- /dev/null
+++ b/src/Testing/src/Logging/LogLevelAttribute.cs
@@ -0,0 +1,19 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+
+namespace Microsoft.Extensions.Logging.Testing
+{
+ [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = false)]
+ public class LogLevelAttribute : Attribute
+ {
+ public LogLevelAttribute(LogLevel logLevel)
+ {
+ LogLevel = logLevel;
+ }
+
+ public LogLevel LogLevel { get; }
+ }
+}
diff --git a/src/Testing/src/Logging/LogValuesAssert.cs b/src/Testing/src/Logging/LogValuesAssert.cs
new file mode 100644
index 0000000000..ef2ff1f406
--- /dev/null
+++ b/src/Testing/src/Logging/LogValuesAssert.cs
@@ -0,0 +1,81 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Xunit.Sdk;
+
+namespace Microsoft.Extensions.Logging.Testing
+{
+ public static class LogValuesAssert
+ {
+ ///
+ /// Asserts that the given key and value are present in the actual values.
+ ///
+ /// The key of the item to be found.
+ /// The value of the item to be found.
+ /// The actual values.
+ public static void Contains(
+ string key,
+ object value,
+ IEnumerable> actualValues)
+ {
+ Contains(new[] { new KeyValuePair(key, value) }, actualValues);
+ }
+
+ ///
+ /// Asserts that all the expected values are present in the actual values by ignoring
+ /// the order of values.
+ ///
+ /// Expected subset of values
+ /// Actual set of values
+ public static void Contains(
+ IEnumerable> expectedValues,
+ IEnumerable> actualValues)
+ {
+ if (expectedValues == null)
+ {
+ throw new ArgumentNullException(nameof(expectedValues));
+ }
+
+ if (actualValues == null)
+ {
+ throw new ArgumentNullException(nameof(actualValues));
+ }
+
+ var comparer = new LogValueComparer();
+
+ foreach (var expectedPair in expectedValues)
+ {
+ if (!actualValues.Contains(expectedPair, comparer))
+ {
+ throw new EqualException(
+ expected: GetString(expectedValues),
+ actual: GetString(actualValues));
+ }
+ }
+ }
+
+ private static string GetString(IEnumerable> logValues)
+ {
+ return string.Join(",", logValues.Select(kvp => $"[{kvp.Key} {kvp.Value}]"));
+ }
+
+ private class LogValueComparer : IEqualityComparer>
+ {
+ public bool Equals(KeyValuePair x, KeyValuePair y)
+ {
+ return string.Equals(x.Key, y.Key) && object.Equals(x.Value, y.Value);
+ }
+
+ public int GetHashCode(KeyValuePair obj)
+ {
+ // We are never going to put this KeyValuePair in a hash table,
+ // so this is ok.
+ throw new NotImplementedException();
+ }
+ }
+ }
+}
diff --git a/src/Testing/src/Logging/TestLogger.cs b/src/Testing/src/Logging/TestLogger.cs
new file mode 100644
index 0000000000..1f1b1d6aba
--- /dev/null
+++ b/src/Testing/src/Logging/TestLogger.cs
@@ -0,0 +1,77 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+
+namespace Microsoft.Extensions.Logging.Testing
+{
+ public class TestLogger : ILogger
+ {
+ private object _scope;
+ private readonly ITestSink _sink;
+ private readonly string _name;
+ private readonly Func _filter;
+
+ public TestLogger(string name, ITestSink sink, bool enabled)
+ : this(name, sink, _ => enabled)
+ {
+ }
+
+ public TestLogger(string name, ITestSink sink, Func filter)
+ {
+ _sink = sink;
+ _name = name;
+ _filter = filter;
+ }
+
+ public string Name { get; set; }
+
+ public IDisposable BeginScope(TState state)
+ {
+ _scope = state;
+
+ _sink.Begin(new BeginScopeContext()
+ {
+ LoggerName = _name,
+ Scope = state,
+ });
+
+ return TestDisposable.Instance;
+ }
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter)
+ {
+ if (!IsEnabled(logLevel))
+ {
+ return;
+ }
+
+ _sink.Write(new WriteContext()
+ {
+ LogLevel = logLevel,
+ EventId = eventId,
+ State = state,
+ Exception = exception,
+ Formatter = (s, e) => formatter((TState)s, e),
+ LoggerName = _name,
+ Scope = _scope
+ });
+ }
+
+ public bool IsEnabled(LogLevel logLevel)
+ {
+ return logLevel != LogLevel.None && _filter(logLevel);
+ }
+
+ private class TestDisposable : IDisposable
+ {
+ public static readonly TestDisposable Instance = new TestDisposable();
+
+ public void Dispose()
+ {
+ // intentionally does nothing
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Testing/src/Logging/TestLoggerFactory.cs b/src/Testing/src/Logging/TestLoggerFactory.cs
new file mode 100644
index 0000000000..a7f2f1398c
--- /dev/null
+++ b/src/Testing/src/Logging/TestLoggerFactory.cs
@@ -0,0 +1,31 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace Microsoft.Extensions.Logging.Testing
+{
+ public class TestLoggerFactory : ILoggerFactory
+ {
+ private readonly ITestSink _sink;
+ private readonly bool _enabled;
+
+ public TestLoggerFactory(ITestSink sink, bool enabled)
+ {
+ _sink = sink;
+ _enabled = enabled;
+ }
+
+ public ILogger CreateLogger(string name)
+ {
+ return new TestLogger(name, _sink, _enabled);
+ }
+
+ public void AddProvider(ILoggerProvider provider)
+ {
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/src/Testing/src/Logging/TestLoggerProvider.cs b/src/Testing/src/Logging/TestLoggerProvider.cs
new file mode 100644
index 0000000000..e604bda36e
--- /dev/null
+++ b/src/Testing/src/Logging/TestLoggerProvider.cs
@@ -0,0 +1,25 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace Microsoft.Extensions.Logging.Testing
+{
+ public class TestLoggerProvider : ILoggerProvider
+ {
+ private readonly ITestSink _sink;
+
+ public TestLoggerProvider(ITestSink sink)
+ {
+ _sink = sink;
+ }
+
+ public ILogger CreateLogger(string categoryName)
+ {
+ return new TestLogger(categoryName, _sink, enabled: true);
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/src/Testing/src/Logging/TestLoggerT.cs b/src/Testing/src/Logging/TestLoggerT.cs
new file mode 100644
index 0000000000..096bb96535
--- /dev/null
+++ b/src/Testing/src/Logging/TestLoggerT.cs
@@ -0,0 +1,38 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+
+namespace Microsoft.Extensions.Logging.Testing
+{
+ public class TestLogger : ILogger
+ {
+ private readonly ILogger _logger;
+
+ public TestLogger(TestLoggerFactory factory)
+ {
+ _logger = factory.CreateLogger();
+ }
+
+ public IDisposable BeginScope(TState state)
+ {
+ return _logger.BeginScope(state);
+ }
+
+ public bool IsEnabled(LogLevel logLevel)
+ {
+ return _logger.IsEnabled(logLevel);
+ }
+
+ public void Log(
+ LogLevel logLevel,
+ EventId eventId,
+ TState state,
+ Exception exception,
+ Func formatter)
+ {
+ _logger.Log(logLevel, eventId, state, exception, formatter);
+ }
+ }
+}
diff --git a/src/Testing/src/Logging/TestSink.cs b/src/Testing/src/Logging/TestSink.cs
new file mode 100644
index 0000000000..5285b3068f
--- /dev/null
+++ b/src/Testing/src/Logging/TestSink.cs
@@ -0,0 +1,66 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Concurrent;
+
+namespace Microsoft.Extensions.Logging.Testing
+{
+ public class TestSink : ITestSink
+ {
+ private ConcurrentQueue _scopes;
+ private ConcurrentQueue _writes;
+
+ public TestSink(
+ Func writeEnabled = null,
+ Func beginEnabled = null)
+ {
+ WriteEnabled = writeEnabled;
+ BeginEnabled = beginEnabled;
+
+ _scopes = new ConcurrentQueue();
+ _writes = new ConcurrentQueue();
+ }
+
+ public Func WriteEnabled { get; set; }
+
+ public Func BeginEnabled { get; set; }
+
+ public IProducerConsumerCollection Scopes { get => _scopes; set => _scopes = new ConcurrentQueue(value); }
+
+ public IProducerConsumerCollection Writes { get => _writes; set => _writes = new ConcurrentQueue(value); }
+
+ public event Action MessageLogged;
+
+ public event Action ScopeStarted;
+
+ public void Write(WriteContext context)
+ {
+ if (WriteEnabled == null || WriteEnabled(context))
+ {
+ _writes.Enqueue(context);
+ }
+ MessageLogged?.Invoke(context);
+ }
+
+ public void Begin(BeginScopeContext context)
+ {
+ if (BeginEnabled == null || BeginEnabled(context))
+ {
+ _scopes.Enqueue(context);
+ }
+ ScopeStarted?.Invoke(context);
+ }
+
+ public static bool EnableWithTypeName(WriteContext context)
+ {
+ return context.LoggerName.Equals(typeof(T).FullName);
+ }
+
+ public static bool EnableWithTypeName(BeginScopeContext context)
+ {
+ return context.LoggerName.Equals(typeof(T).FullName);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Testing/src/Logging/WriteContext.cs b/src/Testing/src/Logging/WriteContext.cs
new file mode 100644
index 0000000000..0ecfc8f1a9
--- /dev/null
+++ b/src/Testing/src/Logging/WriteContext.cs
@@ -0,0 +1,33 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+
+namespace Microsoft.Extensions.Logging.Testing
+{
+ public class WriteContext
+ {
+ public LogLevel LogLevel { get; set; }
+
+ public EventId EventId { get; set; }
+
+ public object State { get; set; }
+
+ public Exception Exception { get; set; }
+
+ public Func Formatter { get; set; }
+
+ public object Scope { get; set; }
+
+ public string LoggerName { get; set; }
+
+ public string Message
+ {
+ get
+ {
+ return Formatter(State, Exception);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Testing/src/Logging/XunitLoggerFactoryExtensions.cs b/src/Testing/src/Logging/XunitLoggerFactoryExtensions.cs
new file mode 100644
index 0000000000..7d053d45dd
--- /dev/null
+++ b/src/Testing/src/Logging/XunitLoggerFactoryExtensions.cs
@@ -0,0 +1,50 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Testing;
+using Xunit.Abstractions;
+
+namespace Microsoft.Extensions.Logging
+{
+ public static class XunitLoggerFactoryExtensions
+ {
+ public static ILoggingBuilder AddXunit(this ILoggingBuilder builder, ITestOutputHelper output)
+ {
+ builder.Services.AddSingleton(new XunitLoggerProvider(output));
+ return builder;
+ }
+
+ public static ILoggingBuilder AddXunit(this ILoggingBuilder builder, ITestOutputHelper output, LogLevel minLevel)
+ {
+ builder.Services.AddSingleton(new XunitLoggerProvider(output, minLevel));
+ return builder;
+ }
+
+ public static ILoggingBuilder AddXunit(this ILoggingBuilder builder, ITestOutputHelper output, LogLevel minLevel, DateTimeOffset? logStart)
+ {
+ builder.Services.AddSingleton(new XunitLoggerProvider(output, minLevel, logStart));
+ return builder;
+ }
+
+ public static ILoggerFactory AddXunit(this ILoggerFactory loggerFactory, ITestOutputHelper output)
+ {
+ loggerFactory.AddProvider(new XunitLoggerProvider(output));
+ return loggerFactory;
+ }
+
+ public static ILoggerFactory AddXunit(this ILoggerFactory loggerFactory, ITestOutputHelper output, LogLevel minLevel)
+ {
+ loggerFactory.AddProvider(new XunitLoggerProvider(output, minLevel));
+ return loggerFactory;
+ }
+
+ public static ILoggerFactory AddXunit(this ILoggerFactory loggerFactory, ITestOutputHelper output, LogLevel minLevel, DateTimeOffset? logStart)
+ {
+ loggerFactory.AddProvider(new XunitLoggerProvider(output, minLevel, logStart));
+ return loggerFactory;
+ }
+ }
+}
diff --git a/src/Testing/src/Logging/XunitLoggerProvider.cs b/src/Testing/src/Logging/XunitLoggerProvider.cs
new file mode 100644
index 0000000000..3a1d751413
--- /dev/null
+++ b/src/Testing/src/Logging/XunitLoggerProvider.cs
@@ -0,0 +1,127 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Linq;
+using System.Text;
+using Xunit.Abstractions;
+
+namespace Microsoft.Extensions.Logging.Testing
+{
+ public class XunitLoggerProvider : ILoggerProvider
+ {
+ private readonly ITestOutputHelper _output;
+ private readonly LogLevel _minLevel;
+ private readonly DateTimeOffset? _logStart;
+
+ public XunitLoggerProvider(ITestOutputHelper output)
+ : this(output, LogLevel.Trace)
+ {
+ }
+
+ public XunitLoggerProvider(ITestOutputHelper output, LogLevel minLevel)
+ : this(output, minLevel, null)
+ {
+ }
+
+ public XunitLoggerProvider(ITestOutputHelper output, LogLevel minLevel, DateTimeOffset? logStart)
+ {
+ _output = output;
+ _minLevel = minLevel;
+ _logStart = logStart;
+ }
+
+ public ILogger CreateLogger(string categoryName)
+ {
+ return new XunitLogger(_output, categoryName, _minLevel, _logStart);
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+
+ public class XunitLogger : ILogger
+ {
+ private static readonly string[] NewLineChars = new[] { Environment.NewLine };
+ private readonly string _category;
+ private readonly LogLevel _minLogLevel;
+ private readonly ITestOutputHelper _output;
+ private DateTimeOffset? _logStart;
+
+ public XunitLogger(ITestOutputHelper output, string category, LogLevel minLogLevel, DateTimeOffset? logStart)
+ {
+ _minLogLevel = minLogLevel;
+ _category = category;
+ _output = output;
+ _logStart = logStart;
+ }
+
+ public void Log(
+ LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter)
+ {
+ if (!IsEnabled(logLevel))
+ {
+ return;
+ }
+
+ // Buffer the message into a single string in order to avoid shearing the message when running across multiple threads.
+ var messageBuilder = new StringBuilder();
+
+ var timestamp = _logStart.HasValue ? $"{(DateTimeOffset.UtcNow - _logStart.Value).TotalSeconds.ToString("N3")}s" : DateTimeOffset.UtcNow.ToString("s");
+
+ var firstLinePrefix = $"| [{timestamp}] {_category} {logLevel}: ";
+ var lines = formatter(state, exception).Split(NewLineChars, StringSplitOptions.RemoveEmptyEntries);
+ messageBuilder.AppendLine(firstLinePrefix + lines.FirstOrDefault() ?? string.Empty);
+
+ var additionalLinePrefix = "|" + new string(' ', firstLinePrefix.Length - 1);
+ foreach (var line in lines.Skip(1))
+ {
+ messageBuilder.AppendLine(additionalLinePrefix + line);
+ }
+
+ if (exception != null)
+ {
+ lines = exception.ToString().Split(NewLineChars, StringSplitOptions.RemoveEmptyEntries);
+ additionalLinePrefix = "| ";
+ foreach (var line in lines)
+ {
+ messageBuilder.AppendLine(additionalLinePrefix + line);
+ }
+ }
+
+ // Remove the last line-break, because ITestOutputHelper only has WriteLine.
+ var message = messageBuilder.ToString();
+ if (message.EndsWith(Environment.NewLine))
+ {
+ message = message.Substring(0, message.Length - Environment.NewLine.Length);
+ }
+
+ try
+ {
+ _output.WriteLine(message);
+ }
+ catch (Exception)
+ {
+ // We could fail because we're on a background thread and our captured ITestOutputHelper is
+ // busted (if the test "completed" before the background thread fired).
+ // So, ignore this. There isn't really anything we can do but hope the
+ // caller has additional loggers registered
+ }
+ }
+
+ public bool IsEnabled(LogLevel logLevel)
+ => logLevel >= _minLogLevel;
+
+ public IDisposable BeginScope(TState state)
+ => new NullScope();
+
+ private class NullScope : IDisposable
+ {
+ public void Dispose()
+ {
+ }
+ }
+ }
+}
diff --git a/src/Testing/src/Microsoft.AspNetCore.Testing.csproj b/src/Testing/src/Microsoft.AspNetCore.Testing.csproj
index 5ddad7b645..7d95e026a7 100644
--- a/src/Testing/src/Microsoft.AspNetCore.Testing.csproj
+++ b/src/Testing/src/Microsoft.AspNetCore.Testing.csproj
@@ -19,7 +19,9 @@
-
+
+
+
diff --git a/src/Testing/test/LogValuesAssertTest.cs b/src/Testing/test/LogValuesAssertTest.cs
new file mode 100644
index 0000000000..dc2db9d83d
--- /dev/null
+++ b/src/Testing/test/LogValuesAssertTest.cs
@@ -0,0 +1,222 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Collections.Generic;
+using System.Linq;
+using Xunit;
+using Xunit.Sdk;
+
+namespace Microsoft.Extensions.Logging.Testing.Tests
+{
+ public class LogValuesAssertTest
+ {
+ public static TheoryData<
+ IEnumerable>,
+ IEnumerable>> ExpectedValues_SubsetOf_ActualValuesData
+ {
+ get
+ {
+ return new TheoryData<
+ IEnumerable>,
+ IEnumerable>>()
+ {
+ {
+ new KeyValuePair[] { },
+ new KeyValuePair[] { }
+ },
+ {
+ // subset
+ new KeyValuePair[] { },
+ new[]
+ {
+ new KeyValuePair("RouteValue", "Failure"),
+ new KeyValuePair("RouteKey", "id")
+ }
+ },
+ {
+ // subset
+ new[]
+ {
+ new KeyValuePair("RouteValue", "Failure"),
+ new KeyValuePair("RouteKey", "id")
+ },
+ new[]
+ {
+ new KeyValuePair("RouteValue", "Failure"),
+ new KeyValuePair("RouteKey", "id"),
+ new KeyValuePair("RouteConstraint", "Something")
+ }
+ },
+ {
+ // equal number of values
+ new[]
+ {
+ new KeyValuePair("RouteValue", "Failure"),
+ new KeyValuePair("RouteKey", "id")
+ },
+ new[]
+ {
+ new KeyValuePair("RouteValue", "Failure"),
+ new KeyValuePair("RouteKey", "id"),
+ }
+ }
+ };
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(ExpectedValues_SubsetOf_ActualValuesData))]
+ public void Asserts_Success_ExpectedValues_SubsetOf_ActualValues(
+ IEnumerable> expectedValues,
+ IEnumerable> actualValues)
+ {
+ // Act && Assert
+ LogValuesAssert.Contains(expectedValues, actualValues);
+ }
+
+ public static TheoryData<
+ IEnumerable>,
+ IEnumerable>> ExpectedValues_MoreThan_ActualValuesData
+ {
+ get
+ {
+ return new TheoryData<
+ IEnumerable>,
+ IEnumerable>>()
+ {
+ {
+ new[]
+ {
+ new KeyValuePair("RouteValue", "Failure"),
+ new KeyValuePair("RouteKey", "id")
+ },
+ new KeyValuePair[] { }
+ },
+ {
+ new[]
+ {
+ new KeyValuePair("RouteValue", "Failure"),
+ new KeyValuePair("RouteKey", "id"),
+ new KeyValuePair("RouteConstraint", "Something")
+ },
+ new[]
+ {
+ new KeyValuePair("RouteValue", "Failure"),
+ new KeyValuePair("RouteKey", "id")
+ }
+ }
+ };
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(ExpectedValues_MoreThan_ActualValuesData))]
+ public void Asserts_Failure_ExpectedValues_MoreThan_ActualValues(
+ IEnumerable> expectedValues,
+ IEnumerable> actualValues)
+ {
+ // Act && Assert
+ var equalException = Assert.Throws(
+ () => LogValuesAssert.Contains(expectedValues, actualValues));
+
+ Assert.Equal(GetString(expectedValues), equalException.Expected);
+ Assert.Equal(GetString(actualValues), equalException.Actual);
+ }
+
+ [Fact]
+ public void Asserts_Success_IgnoringOrderOfItems()
+ {
+ // Arrange
+ var expectedLogValues = new[]
+ {
+ new KeyValuePair("RouteConstraint", "Something"),
+ new KeyValuePair("RouteValue", "Failure"),
+ new KeyValuePair("RouteKey", "id")
+ };
+ var actualLogValues = new[]
+ {
+ new KeyValuePair("RouteKey", "id"),
+ new KeyValuePair("RouteConstraint", "Something"),
+ new KeyValuePair("RouteValue", "Failure"),
+ };
+
+ // Act && Assert
+ LogValuesAssert.Contains(expectedLogValues, actualLogValues);
+ }
+
+ [Fact]
+ public void Asserts_Success_OnSpecifiedKeyAndValue()
+ {
+ // Arrange
+ var actualLogValues = new[]
+ {
+ new KeyValuePair("RouteConstraint", "Something"),
+ new KeyValuePair("RouteKey", "id"),
+ new KeyValuePair("RouteValue", "Failure"),
+ };
+
+ // Act && Assert
+ LogValuesAssert.Contains("RouteKey", "id", actualLogValues);
+ }
+
+ public static TheoryData<
+ IEnumerable>,
+ IEnumerable>> CaseSensitivityComparisionData
+ {
+ get
+ {
+ return new TheoryData<
+ IEnumerable>,
+ IEnumerable>>()
+ {
+ {
+ new[]
+ {
+ new KeyValuePair("RouteKey", "id"),
+ new KeyValuePair("RouteValue", "Failure"),
+ },
+ new[]
+ {
+ new KeyValuePair("ROUTEKEY", "id"),
+ new KeyValuePair("RouteValue", "Failure"),
+ }
+ },
+ {
+ new[]
+ {
+ new KeyValuePair("RouteKey", "id"),
+ new KeyValuePair("RouteValue", "Failure"),
+ },
+ new[]
+ {
+ new KeyValuePair("RouteKey", "id"),
+ new KeyValuePair("RouteValue", "FAILURE"),
+ }
+ }
+ };
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(CaseSensitivityComparisionData))]
+ public void DefaultComparer_Performs_CaseSensitiveComparision(
+ IEnumerable> expectedValues,
+ IEnumerable> actualValues)
+ {
+ // Act && Assert
+ var equalException = Assert.Throws(
+ () => LogValuesAssert.Contains(expectedValues, actualValues));
+
+ Assert.Equal(GetString(expectedValues), equalException.Expected);
+ Assert.Equal(GetString(actualValues), equalException.Actual);
+ }
+
+ private string GetString(IEnumerable> logValues)
+ {
+ return logValues == null ?
+ "Null" :
+ string.Join(",", logValues.Select(kvp => $"[{kvp.Key} {kvp.Value}]"));
+ }
+ }
+}
diff --git a/src/Testing/test/XunitLoggerProviderTest.cs b/src/Testing/test/XunitLoggerProviderTest.cs
new file mode 100644
index 0000000000..e43447c465
--- /dev/null
+++ b/src/Testing/test/XunitLoggerProviderTest.cs
@@ -0,0 +1,95 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Microsoft.Extensions.Logging.Testing.Tests
+{
+ public class XunitLoggerProviderTest
+ {
+ [Fact]
+ public void LoggerProviderWritesToTestOutputHelper()
+ {
+ var testTestOutputHelper = new TestTestOutputHelper();
+
+ var loggerFactory = CreateTestLogger(builder => builder
+ .SetMinimumLevel(LogLevel.Trace)
+ .AddXunit(testTestOutputHelper));
+
+ var logger = loggerFactory.CreateLogger("TestCategory");
+ logger.LogInformation("This is some great information");
+ logger.LogTrace("This is some unimportant information");
+
+ var expectedOutput =
+ "| [TIMESTAMP] TestCategory Information: This is some great information" + Environment.NewLine +
+ "| [TIMESTAMP] TestCategory Trace: This is some unimportant information" + Environment.NewLine;
+
+ Assert.Equal(expectedOutput, MakeConsistent(testTestOutputHelper.Output));
+ }
+
+ [Fact]
+ public void LoggerProviderDoesNotWriteLogMessagesBelowMinimumLevel()
+ {
+ var testTestOutputHelper = new TestTestOutputHelper();
+ var loggerFactory = CreateTestLogger(builder => builder
+ .AddXunit(testTestOutputHelper, LogLevel.Warning));
+
+ var logger = loggerFactory.CreateLogger("TestCategory");
+ logger.LogInformation("This is some great information");
+ logger.LogError("This is a bad error");
+
+ Assert.Equal("| [TIMESTAMP] TestCategory Error: This is a bad error" + Environment.NewLine, MakeConsistent(testTestOutputHelper.Output));
+ }
+
+ [Fact]
+ public void LoggerProviderPrependsPrefixToEachLine()
+ {
+ var testTestOutputHelper = new TestTestOutputHelper();
+ var loggerFactory = CreateTestLogger(builder => builder
+ .AddXunit(testTestOutputHelper));
+
+ var logger = loggerFactory.CreateLogger("TestCategory");
+ logger.LogInformation("This is a" + Environment.NewLine + "multi-line" + Environment.NewLine + "message");
+
+ // The lines after the first one are indented more because the indentation was calculated based on the timestamp's actual length.
+ var expectedOutput =
+ "| [TIMESTAMP] TestCategory Information: This is a" + Environment.NewLine +
+ "| multi-line" + Environment.NewLine +
+ "| message" + Environment.NewLine;
+
+ Assert.Equal(expectedOutput, MakeConsistent(testTestOutputHelper.Output));
+ }
+
+ [Fact]
+ public void LoggerProviderDoesNotThrowIfOutputHelperThrows()
+ {
+ var testTestOutputHelper = new TestTestOutputHelper();
+ var loggerFactory = CreateTestLogger(builder => builder
+
+ .AddXunit(testTestOutputHelper));
+
+ testTestOutputHelper.Throw = true;
+
+ var logger = loggerFactory.CreateLogger("TestCategory");
+ logger.LogInformation("This is a" + Environment.NewLine + "multi-line" + Environment.NewLine + "message");
+
+ Assert.Equal(0, testTestOutputHelper.Output.Length);
+ }
+
+ private static readonly Regex TimestampRegex = new Regex(@"\d+-\d+-\d+T\d+:\d+:\d+");
+
+ private string MakeConsistent(string input) => TimestampRegex.Replace(input, "TIMESTAMP");
+
+ private static ILoggerFactory CreateTestLogger(Action configure)
+ {
+ return new ServiceCollection()
+ .AddLogging(configure)
+ .BuildServiceProvider()
+ .GetRequiredService();
+ }
+ }
+}