commit 018907bec0eedae04669aec564e737b0e459eb33 Author: Nate McMaster Date: Tue Nov 6 13:11:45 2018 -0800 Reorganize source code in preparation to move into aspnet/Extensions Prior to reorganization, this source code was found in https://github.com/aspnet/Logging/tree/8270c545224e8734d7297e54edef5c584ee82f01 diff --git a/src/Logging/Logging.AzureAppServices/Directory.Build.props b/src/Logging/Logging.AzureAppServices/Directory.Build.props new file mode 100644 index 0000000000..f25c1d90ce --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/Directory.Build.props @@ -0,0 +1,7 @@ + + + + + true + + diff --git a/src/Logging/Logging.AzureAppServices/src/AzureAppServicesDiagnosticsSettings.cs b/src/Logging/Logging.AzureAppServices/src/AzureAppServicesDiagnosticsSettings.cs new file mode 100644 index 0000000000..f48480c70f --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/AzureAppServicesDiagnosticsSettings.cs @@ -0,0 +1,161 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Logging.AzureAppServices +{ + /// + /// Settings for Azure diagnostics logging. + /// + public class AzureAppServicesDiagnosticsSettings + { + private TimeSpan _blobCommitPeriod = TimeSpan.FromSeconds(5); + private int _blobBatchSize = 32; + private string _outputTemplate = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level}] {Message}{NewLine}{Exception}"; + private int _retainedFileCountLimit = 2; + private int _fileSizeLimit = 10 * 1024 * 1024; + private string _blobName = "applicationLog.txt"; + private TimeSpan? _fileFlushPeriod = TimeSpan.FromSeconds(1); + private int _backgroundQueueSize; + + /// + /// Gets or sets a strictly positive value representing the maximum log size in bytes. + /// 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. + /// 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 message template describing the output messages. + /// Defaults to "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level}] {Message}{NewLine}{Exception}". + /// + public string OutputTemplate + { + get { return _outputTemplate; } + set + { + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException(nameof(value), $"{nameof(OutputTemplate)} must be non-empty string."); + } + _outputTemplate = value; + } + } + + /// + /// Gets or sets a maximum number of events to include in a single blob append batch. + /// Defaults to 32. + /// + public int BlobBatchSize + { + get { return _blobBatchSize; } + set + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value), $"{nameof(BlobBatchSize)} must be positive."); + } + _blobBatchSize = value; + } + } + + /// + /// Gets or sets a time to wait between checking for blob log batches. + /// Defaults to 5 seconds. + /// + public TimeSpan BlobCommitPeriod + { + get { return _blobCommitPeriod; } + set + { + if (value < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(value), $"{nameof(BlobCommitPeriod)} must be positive."); + } + _blobCommitPeriod = value; + } + } + + /// + /// 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; + } + } + + /// + /// Gets or sets the maximum size of the background log message queue or 0 for no limit. + /// After maximum queue size is reached log event sink would start blocking. + /// Defaults to 0. + /// + 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 the period after which logs will be flushed to disk or + /// null if auto flushing is not required. + /// Defaults to 1 second. + /// + public TimeSpan? FileFlushPeriod + { + get { return _fileFlushPeriod; } + set + { + if (value < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(value), $"{nameof(FileFlushPeriod)} must be positive."); + } + _fileFlushPeriod = value; + } + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/src/AzureAppServicesLoggerFactoryExtensions.cs b/src/Logging/Logging.AzureAppServices/src/AzureAppServicesLoggerFactoryExtensions.cs new file mode 100644 index 0000000000..7970133d38 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/AzureAppServicesLoggerFactoryExtensions.cs @@ -0,0 +1,189 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging.AzureAppServices; +using Microsoft.Extensions.Logging.AzureAppServices.Internal; +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; + } + + 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)); + } + + if (addedBlobLogger) + { + services.AddSingleton>(CreateBlobFilterConfigureOptions(config)); + services.AddSingleton>(new BlobLoggerConfigureOptions(config, context)); + services.AddSingleton>( + new ConfigurationChangeTokenSource(config)); + } + + 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"); + } + + /// + /// Adds an Azure Web Apps diagnostics logger. + /// + /// The extension method argument + public static ILoggerFactory AddAzureWebAppDiagnostics(this ILoggerFactory factory) + { + return AddAzureWebAppDiagnostics(factory, new AzureAppServicesDiagnosticsSettings()); + } + + /// + /// Adds an Azure Web Apps diagnostics logger. + /// + /// The extension method argument + /// The setting object to configure loggers. + public static ILoggerFactory AddAzureWebAppDiagnostics(this ILoggerFactory factory, AzureAppServicesDiagnosticsSettings settings) + { + var context = WebAppContext.Default; + if (!context.IsRunningInAzureWebApp) + { + return factory; + } + + var config = SiteConfigurationProvider.GetAzureLoggingConfiguration(context); + + // Only add the provider if we're in Azure WebApp. That cannot change once the apps started + var fileOptions = new OptionsMonitor( + new OptionsFactory( + new IConfigureOptions[] + { + new FileLoggerConfigureOptions(config, context), + new ConfigureOptions(options => + { + options.FileSizeLimit = settings.FileSizeLimit; + options.RetainedFileCountLimit = settings.RetainedFileCountLimit; + options.BackgroundQueueSize = settings.BackgroundQueueSize == 0 ? (int?) null : settings.BackgroundQueueSize; + + if (settings.FileFlushPeriod != null) + { + options.FlushPeriod = settings.FileFlushPeriod.Value; + } + }) + }, + new IPostConfigureOptions[0] + ), + new[] + { + new ConfigurationChangeTokenSource(config) + }, + new OptionsCache() + ); + + var blobOptions = new OptionsMonitor( + new OptionsFactory( + new IConfigureOptions[] { + new BlobLoggerConfigureOptions(config, context), + new ConfigureOptions(options => + { + options.BlobName = settings.BlobName; + options.FlushPeriod = settings.BlobCommitPeriod; + options.BatchSize = settings.BlobBatchSize; + options.BackgroundQueueSize = settings.BackgroundQueueSize == 0 ? (int?) null : settings.BackgroundQueueSize; + }) + }, + new IPostConfigureOptions[0] + ), + new[] + { + new ConfigurationChangeTokenSource(config) + }, + new OptionsCache() + ); + + var filterOptions = new OptionsMonitor( + new OptionsFactory( + new[] + { + CreateFileFilterConfigureOptions(config), + CreateBlobFilterConfigureOptions(config) + }, + new IPostConfigureOptions[0]), + new [] { new ConfigurationChangeTokenSource(config) }, + new OptionsCache()); + + factory.AddProvider(new ForwardingLoggerProvider( + new LoggerFactory( + new ILoggerProvider[] + { + new FileLoggerProvider(fileOptions), + new BlobLoggerProvider(blobOptions) + }, + filterOptions + ) + )); + return factory; + } + } +} diff --git a/src/Logging/Logging.AzureAppServices/src/AzureBlobLoggerOptions.cs b/src/Logging/Logging.AzureAppServices/src/AzureBlobLoggerOptions.cs new file mode 100644 index 0000000000..eb466f4bb9 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/AzureBlobLoggerOptions.cs @@ -0,0 +1,44 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Logging.AzureAppServices.Internal; + +namespace Microsoft.Extensions.Logging.AzureAppServices +{ + /// + /// Options for Azure diagnostics blob logging. + /// + public class AzureBlobLoggerOptions: BatchingLoggerOptions + { + public AzureBlobLoggerOptions() + { + BatchSize = 32; + } + + 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; } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/src/AzureFileLoggerOptions.cs b/src/Logging/Logging.AzureAppServices/src/AzureFileLoggerOptions.cs new file mode 100644 index 0000000000..47795d9954 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/AzureFileLoggerOptions.cs @@ -0,0 +1,72 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Logging.AzureAppServices.Internal; + +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 strictly positive value representing the maximum retained file count or null for no limit. + /// Defaults to 2. + /// + public string FileName + { + get { return _fileName; } + set + { + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException(nameof(value)); + } + _fileName = value; + } + } + + internal string LogDirectory { get; set; } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/src/Internal/BatchLoggerConfigureOptions.cs b/src/Logging/Logging.AzureAppServices/src/Internal/BatchLoggerConfigureOptions.cs new file mode 100644 index 0000000000..3982193dd8 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Internal/BatchLoggerConfigureOptions.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Internal +{ + public 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; + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/src/Internal/BatchingLogger.cs b/src/Logging/Logging.AzureAppServices/src/Internal/BatchingLogger.cs new file mode 100644 index 0000000000..2cfb26582f --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Internal/BatchingLogger.cs @@ -0,0 +1,59 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Text; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Internal +{ + public 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); + 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/Logging.AzureAppServices/src/Internal/BatchingLoggerOptions.cs b/src/Logging/Logging.AzureAppServices/src/Internal/BatchingLoggerOptions.cs new file mode 100644 index 0000000000..ccdf75e561 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Internal/BatchingLoggerOptions.cs @@ -0,0 +1,69 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Internal +{ + public class BatchingLoggerOptions + { + private int? _batchSize = 32; + private int? _backgroundQueueSize; + 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 null. + /// + 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. + /// + 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; } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/src/Internal/BatchingLoggerProvider.cs b/src/Logging/Logging.AzureAppServices/src/Internal/BatchingLoggerProvider.cs new file mode 100644 index 0000000000..79720c5531 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Internal/BatchingLoggerProvider.cs @@ -0,0 +1,163 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Internal +{ + public abstract class BatchingLoggerProvider: ILoggerProvider + { + private readonly List _currentBatch = new List(); + private readonly TimeSpan _interval; + private readonly int? _queueSize; + private readonly int? _batchSize; + private readonly IDisposable _optionsChangeToken; + + private BlockingCollection _messageQueue; + private Task _outputTask; + private CancellationTokenSource _cancellationTokenSource; + + protected 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); + } + + public bool IsEnabled { get; private set; } + + private void UpdateOptions(BatchingLoggerOptions options) + { + var oldIsEnabled = IsEnabled; + IsEnabled = options.IsEnabled; + if (oldIsEnabled != IsEnabled) + { + if (IsEnabled) + { + Start(); + } + else + { + Stop(); + } + } + + } + + protected abstract Task WriteMessagesAsync(IEnumerable messages, CancellationToken token); + + private async Task ProcessLogQueue(object state) + { + while (!_cancellationTokenSource.IsCancellationRequested) + { + var limit = _batchSize ?? int.MaxValue; + + while (limit > 0 && _messageQueue.TryTake(out var message)) + { + _currentBatch.Add(message); + limit--; + } + + if (_currentBatch.Count > 0) + { + try + { + await WriteMessagesAsync(_currentBatch, _cancellationTokenSource.Token); + } + catch + { + // ignored + } + + _currentBatch.Clear(); + } + + await IntervalAsync(_interval, _cancellationTokenSource.Token); + } + } + + protected virtual Task IntervalAsync(TimeSpan interval, CancellationToken cancellationToken) + { + return Task.Delay(interval, cancellationToken); + } + + internal void AddMessage(DateTimeOffset timestamp, string message) + { + if (!_messageQueue.IsAddingCompleted) + { + try + { + _messageQueue.Add(new LogMessage { Message = message, Timestamp = timestamp }, _cancellationTokenSource.Token); + } + 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.Factory.StartNew( + ProcessLogQueue, + null, + TaskCreationOptions.LongRunning); + } + + 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(); + } + } + + public ILogger CreateLogger(string categoryName) + { + return new BatchingLogger(this, categoryName); + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/src/Internal/BlobAppendReferenceWrapper.cs b/src/Logging/Logging.AzureAppServices/src/Internal/BlobAppendReferenceWrapper.cs new file mode 100644 index 0000000000..e0702275cb --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Internal/BlobAppendReferenceWrapper.cs @@ -0,0 +1,96 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Internal +{ + /// + public 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; + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/src/Internal/BlobLoggerConfigureOptions.cs b/src/Logging/Logging.AzureAppServices/src/Internal/BlobLoggerConfigureOptions.cs new file mode 100644 index 0000000000..25ea1b6af6 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Internal/BlobLoggerConfigureOptions.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Internal +{ + public 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; + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/src/Internal/BlobLoggerProvider.cs b/src/Logging/Logging.AzureAppServices/src/Internal/BlobLoggerProvider.cs new file mode 100644 index 0000000000..96c98fa455 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Internal/BlobLoggerProvider.cs @@ -0,0 +1,91 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.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.Internal +{ + /// + /// 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 + /// + /// + 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. + /// + public BlobLoggerProvider( + IOptionsMonitor options, + Func blobReferenceFactory) : + base(options) + { + var value = options.CurrentValue; + _appName = value.ApplicationName; + _fileName = value.ApplicationInstanceId + "_" + value.BlobName; + _blobReferenceFactory = blobReferenceFactory; + _httpClient = new HttpClient(); + } + + protected 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); + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/src/Internal/ConfigurationBasedLevelSwitcher.cs b/src/Logging/Logging.AzureAppServices/src/Internal/ConfigurationBasedLevelSwitcher.cs new file mode 100644 index 0000000000..388a4ed54e --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Internal/ConfigurationBasedLevelSwitcher.cs @@ -0,0 +1,50 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Internal +{ + public 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; + } + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/src/Internal/FileLoggerConfigureOptions.cs b/src/Logging/Logging.AzureAppServices/src/Internal/FileLoggerConfigureOptions.cs new file mode 100644 index 0000000000..00037bca87 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Internal/FileLoggerConfigureOptions.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Internal +{ + public 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"); + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/src/Internal/FileLoggerProvider.cs b/src/Logging/Logging.AzureAppServices/src/Internal/FileLoggerProvider.cs new file mode 100644 index 0000000000..154f609225 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Internal/FileLoggerProvider.cs @@ -0,0 +1,81 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Internal +{ + [ProviderAlias("AzureAppServicesFile")] + public class FileLoggerProvider : BatchingLoggerProvider + { + private readonly string _path; + private readonly string _fileName; + private readonly int? _maxFileSize; + private readonly int? _maxRetainedFiles; + + public FileLoggerProvider(IOptionsMonitor options) : base(options) + { + var loggerOptions = options.CurrentValue; + _path = loggerOptions.LogDirectory; + _fileName = loggerOptions.FileName; + _maxFileSize = loggerOptions.FileSizeLimit; + _maxRetainedFiles = loggerOptions.RetainedFileCountLimit; + } + + protected 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"); + } + + public (int Year, int Month, int Day) GetGrouping(LogMessage message) + { + return (message.Timestamp.Year, message.Timestamp.Month, message.Timestamp.Day); + } + + protected 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(); + } + } + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/src/Internal/ForwardingLoggerProvider.cs b/src/Logging/Logging.AzureAppServices/src/Internal/ForwardingLoggerProvider.cs new file mode 100644 index 0000000000..0474f0ba9e --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Internal/ForwardingLoggerProvider.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Logging.AzureAppServices.Internal +{ + internal class ForwardingLoggerProvider : ILoggerProvider + { + private readonly ILoggerFactory _loggerFactory; + + public ForwardingLoggerProvider(ILoggerFactory loggerFactory) + { + _loggerFactory = loggerFactory; + } + + public void Dispose() + { + _loggerFactory.Dispose(); + } + + public ILogger CreateLogger(string categoryName) + { + return _loggerFactory.CreateLogger(categoryName); + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/src/Internal/ICloudAppendBlob.cs b/src/Logging/Logging.AzureAppServices/src/Internal/ICloudAppendBlob.cs new file mode 100644 index 0000000000..ccca525090 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Internal/ICloudAppendBlob.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Internal +{ + /// + /// Represents an append blob, a type of blob where blocks of data are always committed to the end of the blob. + /// + public interface ICloudAppendBlob + { + /// + /// Initiates an asynchronous operation to open a stream for writing to the blob. + /// + /// A object of type that represents the asynchronous operation. + Task AppendAsync(ArraySegment data, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/src/Internal/IWebAppContext.cs b/src/Logging/Logging.AzureAppServices/src/Internal/IWebAppContext.cs new file mode 100644 index 0000000000..21e2982192 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Internal/IWebAppContext.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Logging.AzureAppServices.Internal +{ + /// + /// Represents an Azure WebApp context + /// + public interface IWebAppContext + { + /// + /// Gets the path to the home folder if running in Azure WebApp + /// + string HomeFolder { get; } + + /// + /// Gets 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/Logging.AzureAppServices/src/Internal/LogMessage.cs b/src/Logging/Logging.AzureAppServices/src/Internal/LogMessage.cs new file mode 100644 index 0000000000..b330f4dda7 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Internal/LogMessage.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Internal +{ + public struct LogMessage + { + public DateTimeOffset Timestamp { get; set; } + public string Message { get; set; } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/src/Internal/SiteConfigurationProvider.cs b/src/Logging/Logging.AzureAppServices/src/Internal/SiteConfigurationProvider.cs new file mode 100644 index 0000000000..b7aa39de2c --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Internal/SiteConfigurationProvider.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Internal +{ + public 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(); + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/src/Internal/WebAppContext.cs b/src/Logging/Logging.AzureAppServices/src/Internal/WebAppContext.cs new file mode 100644 index 0000000000..774020afdb --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Internal/WebAppContext.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Internal +{ + /// + /// Represents the default implementation of . + /// + public class WebAppContext : IWebAppContext + { + /// + /// Gets the default instance of the WebApp context. + /// + public static WebAppContext Default { get; } = new WebAppContext(); + + private WebAppContext() { } + + /// + public string HomeFolder { get; } = Environment.GetEnvironmentVariable("HOME"); + + /// + public 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/Logging.AzureAppServices/src/Microsoft.Extensions.Logging.AzureAppServices.csproj b/src/Logging/Logging.AzureAppServices/src/Microsoft.Extensions.Logging.AzureAppServices.csproj new file mode 100644 index 0000000000..d6eb186122 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Microsoft.Extensions.Logging.AzureAppServices.csproj @@ -0,0 +1,18 @@ + + + + Logger implementation to support Azure App Services 'Diagnostics logs' and 'Log stream' features. + netstandard2.0 + $(NoWarn);CS1591 + + + + + + + + + + + + diff --git a/src/Logging/Logging.AzureAppServices/src/Properties/AssemblyInfo.cs b/src/Logging/Logging.AzureAppServices/src/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..85c4d7c575 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Runtime.CompilerServices; + + +[assembly: InternalsVisibleTo("Microsoft.Extensions.Logging.AzureAppServices.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Logging/Logging.AzureAppServices/src/baseline.netcore.json b/src/Logging/Logging.AzureAppServices/src/baseline.netcore.json new file mode 100644 index 0000000000..f9b148ceae --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/src/baseline.netcore.json @@ -0,0 +1,368 @@ +{ + "AssemblyIdentity": "Microsoft.Extensions.Logging.AzureAppServices, Version=2.1.1.0, Culture=neutral, PublicKeyToken=adb9793829ddae60", + "Types": [ + { + "Name": "Microsoft.Extensions.Logging.AzureAppServicesLoggerFactoryExtensions", + "Visibility": "Public", + "Kind": "Class", + "Abstract": true, + "Static": true, + "Sealed": true, + "ImplementedInterfaces": [], + "Members": [ + { + "Kind": "Method", + "Name": "AddAzureWebAppDiagnostics", + "Parameters": [ + { + "Name": "builder", + "Type": "Microsoft.Extensions.Logging.ILoggingBuilder" + } + ], + "ReturnType": "Microsoft.Extensions.Logging.ILoggingBuilder", + "Static": true, + "Extension": true, + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "AddAzureWebAppDiagnostics", + "Parameters": [ + { + "Name": "factory", + "Type": "Microsoft.Extensions.Logging.ILoggerFactory" + } + ], + "ReturnType": "Microsoft.Extensions.Logging.ILoggerFactory", + "Static": true, + "Extension": true, + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "AddAzureWebAppDiagnostics", + "Parameters": [ + { + "Name": "factory", + "Type": "Microsoft.Extensions.Logging.ILoggerFactory" + }, + { + "Name": "settings", + "Type": "Microsoft.Extensions.Logging.AzureAppServices.AzureAppServicesDiagnosticsSettings" + } + ], + "ReturnType": "Microsoft.Extensions.Logging.ILoggerFactory", + "Static": true, + "Extension": true, + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.Extensions.Logging.AzureAppServices.AzureAppServicesDiagnosticsSettings", + "Visibility": "Public", + "Kind": "Class", + "ImplementedInterfaces": [], + "Members": [ + { + "Kind": "Method", + "Name": "get_FileSizeLimit", + "Parameters": [], + "ReturnType": "System.Int32", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_FileSizeLimit", + "Parameters": [ + { + "Name": "value", + "Type": "System.Int32" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_RetainedFileCountLimit", + "Parameters": [], + "ReturnType": "System.Int32", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_RetainedFileCountLimit", + "Parameters": [ + { + "Name": "value", + "Type": "System.Int32" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_OutputTemplate", + "Parameters": [], + "ReturnType": "System.String", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_OutputTemplate", + "Parameters": [ + { + "Name": "value", + "Type": "System.String" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_BlobBatchSize", + "Parameters": [], + "ReturnType": "System.Int32", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_BlobBatchSize", + "Parameters": [ + { + "Name": "value", + "Type": "System.Int32" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_BlobCommitPeriod", + "Parameters": [], + "ReturnType": "System.TimeSpan", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_BlobCommitPeriod", + "Parameters": [ + { + "Name": "value", + "Type": "System.TimeSpan" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_BlobName", + "Parameters": [], + "ReturnType": "System.String", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_BlobName", + "Parameters": [ + { + "Name": "value", + "Type": "System.String" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_BackgroundQueueSize", + "Parameters": [], + "ReturnType": "System.Int32", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_BackgroundQueueSize", + "Parameters": [ + { + "Name": "value", + "Type": "System.Int32" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_FileFlushPeriod", + "Parameters": [], + "ReturnType": "System.Nullable", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_FileFlushPeriod", + "Parameters": [ + { + "Name": "value", + "Type": "System.Nullable" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [], + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.Extensions.Logging.AzureAppServices.AzureBlobLoggerOptions", + "Visibility": "Public", + "Kind": "Class", + "BaseType": "Microsoft.Extensions.Logging.AzureAppServices.Internal.BatchingLoggerOptions", + "ImplementedInterfaces": [], + "Members": [ + { + "Kind": "Method", + "Name": "get_BlobName", + "Parameters": [], + "ReturnType": "System.String", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_BlobName", + "Parameters": [ + { + "Name": "value", + "Type": "System.String" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [], + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.Extensions.Logging.AzureAppServices.AzureFileLoggerOptions", + "Visibility": "Public", + "Kind": "Class", + "BaseType": "Microsoft.Extensions.Logging.AzureAppServices.Internal.BatchingLoggerOptions", + "ImplementedInterfaces": [], + "Members": [ + { + "Kind": "Method", + "Name": "get_FileSizeLimit", + "Parameters": [], + "ReturnType": "System.Nullable", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_FileSizeLimit", + "Parameters": [ + { + "Name": "value", + "Type": "System.Nullable" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_RetainedFileCountLimit", + "Parameters": [], + "ReturnType": "System.Nullable", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_RetainedFileCountLimit", + "Parameters": [ + { + "Name": "value", + "Type": "System.Nullable" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_FileName", + "Parameters": [], + "ReturnType": "System.String", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_FileName", + "Parameters": [ + { + "Name": "value", + "Type": "System.String" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [], + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + } + ] +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/test/AzureAppendBlobTests.cs b/src/Logging/Logging.AzureAppServices/test/AzureAppendBlobTests.cs new file mode 100644 index 0000000000..e9fe0b65b1 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/test/AzureAppendBlobTests.cs @@ -0,0 +1,186 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.AzureAppServices.Internal; +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/Logging.AzureAppServices/test/AzureBlobSinkTests.cs b/src/Logging/Logging.AzureAppServices/test/AzureBlobSinkTests.cs new file mode 100644 index 0000000000..a1ee0e97d3 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/test/AzureBlobSinkTests.cs @@ -0,0 +1,98 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; +using Microsoft.Extensions.Logging.AzureAppServices.Internal; + +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/Logging.AzureAppServices/test/AzureDiagnosticsConfigurationProviderTests.cs b/src/Logging/Logging.AzureAppServices/test/AzureDiagnosticsConfigurationProviderTests.cs new file mode 100644 index 0000000000..51ba07f12b --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/test/AzureDiagnosticsConfigurationProviderTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using Microsoft.Extensions.Logging.AzureAppServices.Internal; +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. + } + } + } + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/test/BatchingLoggerProviderTests.cs b/src/Logging/Logging.AzureAppServices/test/BatchingLoggerProviderTests.cs new file mode 100644 index 0000000000..42cefe99df --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/test/BatchingLoggerProviderTests.cs @@ -0,0 +1,111 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.AzureAppServices.Internal; +using Xunit; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Test +{ + public class BatchingLoggerProviderTests + { + DateTimeOffset _timestampOne = new DateTimeOffset(2016, 05, 04, 03, 02, 01, TimeSpan.Zero); + string _nl = Environment.NewLine; + + [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 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.Single(provider.Batches); + 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); + + provider.IntervalControl.Resume(); + await provider.IntervalControl.Pause; + + Assert.Equal(2, provider.Batches.Count); + 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 BlocksWhenReachingMaxQueue() + { + 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); + var task = Task.Run(() => logger.Log(_timestampOne.AddHours(1), LogLevel.Error, 0, "Error message", null, (state, ex) => state)); + + Assert.False(task.Wait(1000)); + + provider.IntervalControl.Resume(); + await provider.IntervalControl.Pause; + + Assert.True(task.Wait(1000)); + } + + 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) + : base(new OptionsWrapperMonitor(new BatchingLoggerOptions + { + FlushPeriod = interval ?? TimeSpan.FromSeconds(1), + BatchSize = maxBatchSize, + BackgroundQueueSize = maxQueueSize, + IsEnabled = true + })) + { + } + + protected 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/Logging.AzureAppServices/test/ConfigureOptionsTests.cs b/src/Logging/Logging.AzureAppServices/test/ConfigureOptionsTests.cs new file mode 100644 index 0000000000..077ebd726a --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/test/ConfigureOptionsTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.AzureAppServices.Internal; +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/Logging.AzureAppServices/test/FileLoggerTests.cs b/src/Logging/Logging.AzureAppServices/test/FileLoggerTests.cs new file mode 100644 index 0000000000..ea838b93cf --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/test/FileLoggerTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.AzureAppServices.Internal; +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); + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/test/LoggerBuilderExtensionsTests.cs b/src/Logging/Logging.AzureAppServices/test/LoggerBuilderExtensionsTests.cs new file mode 100644 index 0000000000..ddf38d0137 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/test/LoggerBuilderExtensionsTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Linq; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.AzureAppServices.Internal; +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))); + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/test/ManualIntervalControl.cs b/src/Logging/Logging.AzureAppServices/test/ManualIntervalControl.cs new file mode 100644 index 0000000000..0ce87f65dc --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/test/ManualIntervalControl.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Test +{ + internal class ManualIntervalControl + { + + private TaskCompletionSource _pauseCompletionSource = new TaskCompletionSource(); + private TaskCompletionSource _resumeCompletionSource; + + public Task Pause => _pauseCompletionSource.Task; + + public void Resume() + { + _pauseCompletionSource = new TaskCompletionSource(); + _resumeCompletionSource.SetResult(null); + } + + public async Task IntervalAsync() + { + _resumeCompletionSource = new TaskCompletionSource(); + _pauseCompletionSource.SetResult(null); + + await _resumeCompletionSource.Task; + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/test/Microsoft.Extensions.Logging.AzureAppServices.Tests.csproj b/src/Logging/Logging.AzureAppServices/test/Microsoft.Extensions.Logging.AzureAppServices.Tests.csproj new file mode 100644 index 0000000000..e89ce311d8 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/test/Microsoft.Extensions.Logging.AzureAppServices.Tests.csproj @@ -0,0 +1,12 @@ + + + + $(StandardTestTfms) + + + + + + + + diff --git a/src/Logging/Logging.AzureAppServices/test/OptionsWrapperMonitor.cs b/src/Logging/Logging.AzureAppServices/test/OptionsWrapperMonitor.cs new file mode 100644 index 0000000000..32da949cdb --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/test/OptionsWrapperMonitor.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Test +{ + internal class OptionsWrapperMonitor : IOptionsMonitor + { + public OptionsWrapperMonitor(T currentValue) + { + CurrentValue = currentValue; + } + + public IDisposable OnChange(Action listener) + { + return null; + } + + public T Get(string name) => CurrentValue; + + public T CurrentValue { get; } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/test/TestBlobSink.cs b/src/Logging/Logging.AzureAppServices/test/TestBlobSink.cs new file mode 100644 index 0000000000..df9665e44e --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/test/TestBlobSink.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.AzureAppServices.Internal; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Test +{ + internal class TestBlobSink : BlobLoggerProvider + { + internal ManualIntervalControl IntervalControl { get; } = new ManualIntervalControl(); + + public TestBlobSink(Func blobReferenceFactory) : base( + new OptionsWrapperMonitor(new AzureBlobLoggerOptions() + { + ApplicationInstanceId = "42", + ApplicationName = "appname", + BlobName = "filename", + IsEnabled = true + }), + blobReferenceFactory) + { + } + + protected override Task IntervalAsync(TimeSpan interval, CancellationToken cancellationToken) + { + return IntervalControl.IntervalAsync(); + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/test/TestFileLoggerProvider.cs b/src/Logging/Logging.AzureAppServices/test/TestFileLoggerProvider.cs new file mode 100644 index 0000000000..4b0b87c4e4 --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/test/TestFileLoggerProvider.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.AzureAppServices.Internal; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Test +{ + internal class TestFileLoggerProvider : FileLoggerProvider + { + internal ManualIntervalControl IntervalControl { get; } = new ManualIntervalControl(); + + public TestFileLoggerProvider( + string path, + string fileName = "LogFile.", + int maxFileSize = 32_000, + int maxRetainedFiles = 100) + : base(new OptionsWrapperMonitor(new AzureFileLoggerOptions() + { + LogDirectory = path, + FileName = fileName, + FileSizeLimit = maxFileSize, + RetainedFileCountLimit = maxRetainedFiles, + IsEnabled = true + })) + { + } + + protected override Task IntervalAsync(TimeSpan interval, CancellationToken cancellationToken) + { + return IntervalControl.IntervalAsync(); + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.AzureAppServices/test/WebConfigurationLevelSwitchTests.cs b/src/Logging/Logging.AzureAppServices/test/WebConfigurationLevelSwitchTests.cs new file mode 100644 index 0000000000..afb5dc037f --- /dev/null +++ b/src/Logging/Logging.AzureAppServices/test/WebConfigurationLevelSwitchTests.cs @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.AzureAppServices.Internal; +using Xunit; + +namespace Microsoft.Extensions.Logging.AzureAppServices.Test +{ + public class WebConfigurationLevelSwitchTests + { + [Theory] + [InlineData("Error", LogLevel.Error)] + [InlineData("Warning", LogLevel.Warning)] + [InlineData("Information", LogLevel.Information)] + [InlineData("Verbose", LogLevel.Trace)] + [InlineData("ABCD", LogLevel.None)] + public void AddsRuleWithCorrectLevel(string levelValue, LogLevel expectedLevel) + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection( + new[] + { + new KeyValuePair("levelKey", levelValue), + }) + .Build(); + + var levelSwitcher = new ConfigurationBasedLevelSwitcher(configuration, typeof(TestFileLoggerProvider), "levelKey"); + + var filterConfiguration = new LoggerFilterOptions(); + levelSwitcher.Configure(filterConfiguration); + + Assert.Equal(1, filterConfiguration.Rules.Count); + + var rule = filterConfiguration.Rules[0]; + Assert.Equal(typeof(TestFileLoggerProvider).FullName, rule.ProviderName); + Assert.Equal(expectedLevel, rule.LogLevel); + } + } +} diff --git a/src/Logging/Logging.Testing/src/AssemblyTestLog.cs b/src/Logging/Logging.Testing/src/AssemblyTestLog.cs new file mode 100644 index 0000000000..97a67b11fa --- /dev/null +++ b/src/Logging/Logging.Testing/src/AssemblyTestLog.cs @@ -0,0 +1,305 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using Serilog.Extensions.Logging; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class AssemblyTestLog : IDisposable + { + public static readonly string OutputDirectoryEnvironmentVariableName = "ASPNETCORE_TEST_LOG_DIR"; + private static readonly string MaxPathLengthEnvironmentVariableName = "ASPNETCORE_TEST_LOG_MAXPATH"; + private static readonly string LogFileExtension = ".log"; + private static readonly int MaxPathLength = GetMaxPathLength(); + private static char[] InvalidFileChars = new char[] + { + '\"', '<', '>', '|', '\0', + (char)1, (char)2, (char)3, (char)4, (char)5, (char)6, (char)7, (char)8, (char)9, (char)10, + (char)11, (char)12, (char)13, (char)14, (char)15, (char)16, (char)17, (char)18, (char)19, (char)20, + (char)21, (char)22, (char)23, (char)24, (char)25, (char)26, (char)27, (char)28, (char)29, (char)30, + (char)31, ':', '*', '?', '\\', '/', ' ', (char)127 + }; + + private static readonly object _lock = new object(); + private static readonly Dictionary _logs = new Dictionary(); + + private readonly ILoggerFactory _globalLoggerFactory; + private readonly ILogger _globalLogger; + private readonly string _baseDirectory; + private readonly string _assemblyName; + private readonly IServiceProvider _serviceProvider; + + private static int GetMaxPathLength() + { + var maxPathString = Environment.GetEnvironmentVariable(MaxPathLengthEnvironmentVariableName); + var defaultMaxPath = 245; + return string.IsNullOrEmpty(maxPathString) ? defaultMaxPath : int.Parse(maxPathString); + } + + private AssemblyTestLog(ILoggerFactory globalLoggerFactory, ILogger globalLogger, string baseDirectory, string assemblyName, IServiceProvider serviceProvider) + { + _globalLoggerFactory = globalLoggerFactory; + _globalLogger = globalLogger; + _baseDirectory = baseDirectory; + _assemblyName = assemblyName; + _serviceProvider = serviceProvider; + } + + public IDisposable StartTestLog(ITestOutputHelper output, string className, out ILoggerFactory loggerFactory, [CallerMemberName] string testName = null) => + StartTestLog(output, className, out loggerFactory, LogLevel.Debug, testName); + + public IDisposable StartTestLog(ITestOutputHelper output, string className, out ILoggerFactory loggerFactory, LogLevel minLogLevel, [CallerMemberName] string testName = null) => + StartTestLog(output, className, out loggerFactory, minLogLevel, out var _, testName); + + internal IDisposable StartTestLog(ITestOutputHelper output, string className, out ILoggerFactory loggerFactory, LogLevel minLogLevel, out string resolvedTestName, [CallerMemberName] string testName = null) + { + var serviceProvider = CreateLoggerServices(output, className, minLogLevel, out resolvedTestName, testName); + var factory = serviceProvider.GetRequiredService(); + loggerFactory = factory; + var logger = loggerFactory.CreateLogger("TestLifetime"); + + var stopwatch = Stopwatch.StartNew(); + + var scope = logger.BeginScope("Test: {testName}", testName); + + _globalLogger.LogInformation("Starting test {testName}", testName); + logger.LogInformation("Starting test {testName}", testName); + + return new Disposable(() => + { + stopwatch.Stop(); + _globalLogger.LogInformation("Finished test {testName} in {duration}s", testName, stopwatch.Elapsed.TotalSeconds); + logger.LogInformation("Finished test {testName} in {duration}s", testName, stopwatch.Elapsed.TotalSeconds); + scope.Dispose(); + factory.Dispose(); + (serviceProvider as IDisposable)?.Dispose(); + }); + } + + public ILoggerFactory CreateLoggerFactory(ITestOutputHelper output, string className, [CallerMemberName] string testName = null) => + CreateLoggerFactory(output, className, LogLevel.Trace, testName); + + public ILoggerFactory CreateLoggerFactory(ITestOutputHelper output, string className, LogLevel minLogLevel, [CallerMemberName] string testName = null) + { + return CreateLoggerServices(output, className, minLogLevel, out var _, testName).GetRequiredService(); + } + + public IServiceProvider CreateLoggerServices(ITestOutputHelper output, string className, LogLevel minLogLevel, out string normalizedTestName, [CallerMemberName] string testName = null) + { + normalizedTestName = string.Empty; + + // Try to shorten the class name using the assembly name + if (className.StartsWith(_assemblyName + ".")) + { + className = className.Substring(_assemblyName.Length + 1); + } + + SerilogLoggerProvider serilogLoggerProvider = null; + if (!string.IsNullOrEmpty(_baseDirectory)) + { + var testOutputDirectory = Path.Combine(GetAssemblyBaseDirectory(_assemblyName, _baseDirectory), className); + testName = RemoveIllegalFileChars(testName); + + if (testOutputDirectory.Length + testName.Length + LogFileExtension.Length >= MaxPathLength) + { + _globalLogger.LogWarning($"Test name {testName} is too long. Please shorten test name."); + + // Shorten the test name by removing the middle portion of the testname + var testNameLength = MaxPathLength - testOutputDirectory.Length - LogFileExtension.Length; + + if (testNameLength <= 0) + { + throw new InvalidOperationException("Output file path could not be constructed due to max path length restrictions. Please shorten test assembly, class or method names."); + } + + testName = testName.Substring(0, testNameLength / 2) + testName.Substring(testName.Length - testNameLength / 2, testNameLength / 2); + + _globalLogger.LogWarning($"To prevent long paths test name was shortened to {testName}."); + } + + var testOutputFile = Path.Combine(testOutputDirectory, $"{testName}{LogFileExtension}"); + + if (File.Exists(testOutputFile)) + { + _globalLogger.LogWarning($"Output log file {testOutputFile} already exists. Please try to keep log file names unique."); + + for (var i = 0; i < 1000; i++) + { + testOutputFile = Path.Combine(testOutputDirectory, $"{testName}.{i}{LogFileExtension}"); + + if (!File.Exists(testOutputFile)) + { + _globalLogger.LogWarning($"To resolve log file collision, the enumerated file {testOutputFile} will be used."); + testName = $"{testName}.{i}"; + break; + } + } + } + + normalizedTestName = testName; + serilogLoggerProvider = ConfigureFileLogging(testOutputFile); + } + + var serviceCollection = new ServiceCollection(); + serviceCollection.AddLogging(builder => + { + builder.SetMinimumLevel(minLogLevel); + + if (output != null) + { + builder.AddXunit(output, minLogLevel); + } + + if (serilogLoggerProvider != null) + { + // Use a factory so that the container will dispose it + builder.Services.AddSingleton(_ => serilogLoggerProvider); + } + }); + + return serviceCollection.BuildServiceProvider(); + } + + public static AssemblyTestLog Create(string assemblyName, string baseDirectory) + { + SerilogLoggerProvider serilogLoggerProvider = null; + var globalLogDirectory = GetAssemblyBaseDirectory(assemblyName, baseDirectory); + if (!string.IsNullOrEmpty(globalLogDirectory)) + { + var globalLogFileName = Path.Combine(globalLogDirectory, "global.log"); + serilogLoggerProvider = ConfigureFileLogging(globalLogFileName); + } + + var serviceCollection = new ServiceCollection(); + + serviceCollection.AddLogging(builder => + { + // Global logging, when it's written, is expected to be outputted. So set the log level to minimum. + builder.SetMinimumLevel(LogLevel.Trace); + + if (serilogLoggerProvider != null) + { + // Use a factory so that the container will dispose it + builder.Services.AddSingleton(_ => serilogLoggerProvider); + } + }); + + var serviceProvider = serviceCollection.BuildServiceProvider(); + var loggerFactory = serviceProvider.GetRequiredService(); + + var logger = loggerFactory.CreateLogger("GlobalTestLog"); + logger.LogInformation($"Global Test Logging initialized. Set the '{OutputDirectoryEnvironmentVariableName}' Environment Variable in order to create log files on disk."); + return new AssemblyTestLog(loggerFactory, logger, baseDirectory, assemblyName, serviceProvider); + } + + public static AssemblyTestLog ForAssembly(Assembly assembly) + { + lock (_lock) + { + if (!_logs.TryGetValue(assembly, out var log)) + { + var assemblyName = assembly.GetName().Name; + var baseDirectory = Environment.GetEnvironmentVariable(OutputDirectoryEnvironmentVariableName); + log = Create(assemblyName, baseDirectory); + _logs[assembly] = log; + + // Try to clear previous logs + var assemblyBaseDirectory = GetAssemblyBaseDirectory(assemblyName, baseDirectory); + if (Directory.Exists(assemblyBaseDirectory)) + { + try + { + Directory.Delete(assemblyBaseDirectory, recursive: true); + } + catch {} + } + } + return log; + } + } + + private static string GetAssemblyBaseDirectory(string assemblyName, string baseDirectory) + { + if (!string.IsNullOrEmpty(baseDirectory)) + { + return Path.Combine(baseDirectory, assemblyName, RuntimeInformation.FrameworkDescription.TrimStart('.')); + } + return string.Empty; + } + + private static SerilogLoggerProvider ConfigureFileLogging(string fileName) + { + var dir = Path.GetDirectoryName(fileName); + if (!Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + if (File.Exists(fileName)) + { + File.Delete(fileName); + } + + var serilogger = new LoggerConfiguration() + .Enrich.FromLogContext() + .MinimumLevel.Verbose() + .WriteTo.File(fileName, outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{SourceContext}] [{Level}] {Message}{NewLine}{Exception}", flushToDiskInterval: TimeSpan.FromSeconds(1), shared: true) + .CreateLogger(); + return new SerilogLoggerProvider(serilogger, dispose: true); + } + + private static string RemoveIllegalFileChars(string s) + { + var sb = new StringBuilder(); + + foreach (var c in s) + { + if (InvalidFileChars.Contains(c)) + { + if (sb.Length > 0 && sb[sb.Length - 1] != '_') + { + sb.Append('_'); + } + } + else + { + sb.Append(c); + } + } + return sb.ToString(); + } + + public void Dispose() + { + (_serviceProvider as IDisposable)?.Dispose(); + _globalLoggerFactory.Dispose(); + } + + private class Disposable : IDisposable + { + private Action _action; + + public Disposable(Action action) + { + _action = action; + } + + public void Dispose() + { + _action(); + } + } + } +} diff --git a/src/Logging/Logging.Testing/src/BeginScopeContext.cs b/src/Logging/Logging.Testing/src/BeginScopeContext.cs new file mode 100644 index 0000000000..b432f1ad7d --- /dev/null +++ b/src/Logging/Logging.Testing/src/BeginScopeContext.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Logging.Testing +{ + public class BeginScopeContext + { + public object Scope { get; set; } + + public string LoggerName { get; set; } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.Testing/src/ITestSink.cs b/src/Logging/Logging.Testing/src/ITestSink.cs new file mode 100644 index 0000000000..bd2d1955ae --- /dev/null +++ b/src/Logging/Logging.Testing/src/ITestSink.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Concurrent; + +namespace Microsoft.Extensions.Logging.Testing +{ + public interface ITestSink + { + 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/Logging/Logging.Testing/src/LogValuesAssert.cs b/src/Logging/Logging.Testing/src/LogValuesAssert.cs new file mode 100644 index 0000000000..ea769e68e2 --- /dev/null +++ b/src/Logging/Logging.Testing/src/LogValuesAssert.cs @@ -0,0 +1,80 @@ +// Copyright(c) .NET Foundation.All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.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/Logging/Logging.Testing/src/LoggedTest.cs b/src/Logging/Logging.Testing/src/LoggedTest.cs new file mode 100644 index 0000000000..58fcc1fb3e --- /dev/null +++ b/src/Logging/Logging.Testing/src/LoggedTest.cs @@ -0,0 +1,45 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Reflection; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Logging.Testing +{ + public abstract class LoggedTest + { + // Obsolete but keeping for back compat + public LoggedTest(ITestOutputHelper output = null) + { + TestOutputHelper = output; + } + + // Internal for testing + internal string ResolvedTestMethodName { get; set; } + + // Internal for testing + internal string ResolvedTestClassName { get; set; } + + public ILogger Logger { get; set; } + + public ILoggerFactory LoggerFactory { get; set; } + + public ITestOutputHelper TestOutputHelper { get; set; } + + public ITestSink TestSink { get; set; } + + public void AddTestLogging(IServiceCollection services) => services.AddSingleton(LoggerFactory); + + public IDisposable StartLog(out ILoggerFactory loggerFactory, [CallerMemberName] string testName = null) => StartLog(out loggerFactory, LogLevel.Information, testName); + + public IDisposable StartLog(out ILoggerFactory loggerFactory, LogLevel minLogLevel, [CallerMemberName] string testName = null) + { + return AssemblyTestLog.ForAssembly(GetType().GetTypeInfo().Assembly).StartTestLog(TestOutputHelper, GetType().FullName, out loggerFactory, minLogLevel, testName); + } + + public virtual void AdditionalSetup() { } + } +} diff --git a/src/Logging/Logging.Testing/src/Microsoft.Extensions.Logging.Testing.csproj b/src/Logging/Logging.Testing/src/Microsoft.Extensions.Logging.Testing.csproj new file mode 100644 index 0000000000..22a1db0243 --- /dev/null +++ b/src/Logging/Logging.Testing/src/Microsoft.Extensions.Logging.Testing.csproj @@ -0,0 +1,26 @@ + + + + Helpers for writing tests that use Microsoft.Extensions.Logging. Contains null implementations of the abstractions that do nothing, as well as test implementations that are observable. + netstandard2.0;net461 + $(NoWarn);CS1591 + $(PackageTags);testing + false + + + + + + + + + + + + + + + + + + diff --git a/src/Logging/Logging.Testing/src/Properties/AssemblyInfo.cs b/src/Logging/Logging.Testing/src/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..a5cc6c1da7 --- /dev/null +++ b/src/Logging/Logging.Testing/src/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.Extensions.Logging.Testing.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Logging/Logging.Testing/src/ShortClassNameAttribute.cs b/src/Logging/Logging.Testing/src/ShortClassNameAttribute.cs new file mode 100644 index 0000000000..bd30718814 --- /dev/null +++ b/src/Logging/Logging.Testing/src/ShortClassNameAttribute.cs @@ -0,0 +1,12 @@ +// Copyright(c) .NET Foundation.All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Logging.Testing +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = false)] + public class ShortClassNameAttribute : Attribute + { + } +} diff --git a/src/Logging/Logging.Testing/src/TestLogger.cs b/src/Logging/Logging.Testing/src/TestLogger.cs new file mode 100644 index 0000000000..f7a73dfa6a --- /dev/null +++ b/src/Logging/Logging.Testing/src/TestLogger.cs @@ -0,0 +1,76 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Logging.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/Logging/Logging.Testing/src/TestLoggerFactory.cs b/src/Logging/Logging.Testing/src/TestLoggerFactory.cs new file mode 100644 index 0000000000..b0513fed66 --- /dev/null +++ b/src/Logging/Logging.Testing/src/TestLoggerFactory.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Logging.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() + { + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.Testing/src/TestLoggerOfT.cs b/src/Logging/Logging.Testing/src/TestLoggerOfT.cs new file mode 100644 index 0000000000..b54870eb87 --- /dev/null +++ b/src/Logging/Logging.Testing/src/TestLoggerOfT.cs @@ -0,0 +1,37 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Logging.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/Logging/Logging.Testing/src/TestLoggerProvider.cs b/src/Logging/Logging.Testing/src/TestLoggerProvider.cs new file mode 100644 index 0000000000..758ff32492 --- /dev/null +++ b/src/Logging/Logging.Testing/src/TestLoggerProvider.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +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/Logging/Logging.Testing/src/TestSink.cs b/src/Logging/Logging.Testing/src/TestSink.cs new file mode 100644 index 0000000000..f67cab5648 --- /dev/null +++ b/src/Logging/Logging.Testing/src/TestSink.cs @@ -0,0 +1,59 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; + +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 void Write(WriteContext context) + { + if (WriteEnabled == null || WriteEnabled(context)) + { + _writes.Enqueue(context); + } + } + + public void Begin(BeginScopeContext context) + { + if (BeginEnabled == null || BeginEnabled(context)) + { + _scopes.Enqueue(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/Logging/Logging.Testing/src/WriteContext.cs b/src/Logging/Logging.Testing/src/WriteContext.cs new file mode 100644 index 0000000000..661538e4c5 --- /dev/null +++ b/src/Logging/Logging.Testing/src/WriteContext.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Logging.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/Logging/Logging.Testing/src/Xunit/LogLevelAttribute.cs b/src/Logging/Logging.Testing/src/Xunit/LogLevelAttribute.cs new file mode 100644 index 0000000000..9f6f621374 --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LogLevelAttribute.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Logging.Testing +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class LogLevelAttribute : Attribute + { + public LogLevelAttribute(LogLevel logLevel) + { + LogLevel = logLevel; + } + + public LogLevel LogLevel { get; } + } +} diff --git a/src/Logging/Logging.Testing/src/Xunit/LoggedConditionalFactDiscoverer.cs b/src/Logging/Logging.Testing/src/Xunit/LoggedConditionalFactDiscoverer.cs new file mode 100644 index 0000000000..ebb9ecf92e --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LoggedConditionalFactDiscoverer.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Testing.xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class LoggedConditionalFactDiscoverer : LoggedFactDiscoverer + { + private readonly IMessageSink _diagnosticMessageSink; + + public LoggedConditionalFactDiscoverer(IMessageSink diagnosticMessageSink) : base(diagnosticMessageSink) + { + _diagnosticMessageSink = diagnosticMessageSink; + } + + protected override IXunitTestCase CreateTestCase(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute) + { + var skipReason = testMethod.EvaluateSkipConditions(); + return skipReason != null + ? new SkippedTestCase(skipReason, _diagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), testMethod) + : base.CreateTestCase(discoveryOptions, testMethod, factAttribute); + } + + } +} diff --git a/src/Logging/Logging.Testing/src/Xunit/LoggedConditionalTheoryDiscoverer.cs b/src/Logging/Logging.Testing/src/Xunit/LoggedConditionalTheoryDiscoverer.cs new file mode 100644 index 0000000000..da5685bbfc --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LoggedConditionalTheoryDiscoverer.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNetCore.Testing.xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class LoggedConditionalTheoryDiscoverer : LoggedTheoryDiscoverer + { + public LoggedConditionalTheoryDiscoverer(IMessageSink diagnosticMessageSink) + : base(diagnosticMessageSink) + { + } + + protected override IEnumerable CreateTestCasesForTheory( + ITestFrameworkDiscoveryOptions discoveryOptions, + ITestMethod testMethod, + IAttributeInfo theoryAttribute) + { + var skipReason = testMethod.EvaluateSkipConditions(); + return skipReason != null + ? new[] { new SkippedTestCase(skipReason, DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), testMethod) } + : base.CreateTestCasesForTheory(discoveryOptions, testMethod, theoryAttribute); + } + + protected override IEnumerable CreateTestCasesForDataRow( + ITestFrameworkDiscoveryOptions discoveryOptions, + ITestMethod testMethod, IAttributeInfo theoryAttribute, + object[] dataRow) + { + var skipReason = testMethod.EvaluateSkipConditions(); + return skipReason != null + ? base.CreateTestCasesForSkippedDataRow(discoveryOptions, testMethod, theoryAttribute, dataRow, skipReason) + : base.CreateTestCasesForDataRow(discoveryOptions, testMethod, theoryAttribute, dataRow); + } + + } +} diff --git a/src/Logging/Logging.Testing/src/Xunit/LoggedFactDiscoverer.cs b/src/Logging/Logging.Testing/src/Xunit/LoggedFactDiscoverer.cs new file mode 100644 index 0000000000..c52d99c822 --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LoggedFactDiscoverer.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class LoggedFactDiscoverer : FactDiscoverer + { + public LoggedFactDiscoverer(IMessageSink diagnosticMessageSink) : base(diagnosticMessageSink) + { + } + + protected override IXunitTestCase CreateTestCase(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute) + => new LoggedTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), testMethod); + } +} diff --git a/src/Logging/Logging.Testing/src/Xunit/LoggedTestAssemblyRunner.cs b/src/Logging/Logging.Testing/src/Xunit/LoggedTestAssemblyRunner.cs new file mode 100644 index 0000000000..39dff8fce7 --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LoggedTestAssemblyRunner.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class LoggedTestAssemblyRunner : XunitTestAssemblyRunner + { + public LoggedTestAssemblyRunner( + ITestAssembly testAssembly, + IEnumerable testCases, + IMessageSink diagnosticMessageSink, + IMessageSink executionMessageSink, + ITestFrameworkExecutionOptions executionOptions) + : base(testAssembly, testCases, diagnosticMessageSink, executionMessageSink, executionOptions) + { + } + + protected override Task RunTestCollectionAsync( + IMessageBus messageBus, + ITestCollection testCollection, + IEnumerable testCases, + CancellationTokenSource cancellationTokenSource) + => new LoggedTestCollectionRunner(testCollection, testCases, DiagnosticMessageSink, messageBus, TestCaseOrderer, new ExceptionAggregator(Aggregator), cancellationTokenSource).RunAsync(); + } +} diff --git a/src/Logging/Logging.Testing/src/Xunit/LoggedTestCase.cs b/src/Logging/Logging.Testing/src/Xunit/LoggedTestCase.cs new file mode 100644 index 0000000000..b8d3684c18 --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LoggedTestCase.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class LoggedTestCase : XunitTestCase + { + [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] + public LoggedTestCase() : base() + { + } + + public LoggedTestCase( + IMessageSink diagnosticMessageSink, + TestMethodDisplay defaultMethodDisplay, + ITestMethod testMethod, + object[] testMethodArguments = null) + : base(diagnosticMessageSink, defaultMethodDisplay, testMethod, testMethodArguments) + { + } + + public override Task RunAsync( + IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + object[] constructorArguments, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + => new LoggedTestCaseRunner(this, DisplayName, SkipReason, constructorArguments, TestMethodArguments, messageBus, aggregator, cancellationTokenSource).RunAsync(); + } +} diff --git a/src/Logging/Logging.Testing/src/Xunit/LoggedTestCaseRunner.cs b/src/Logging/Logging.Testing/src/Xunit/LoggedTestCaseRunner.cs new file mode 100644 index 0000000000..20cde6144e --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LoggedTestCaseRunner.cs @@ -0,0 +1,42 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class LoggedTestCaseRunner : XunitTestCaseRunner + { + public LoggedTestCaseRunner( + IXunitTestCase testCase, + string displayName, + string skipReason, + object[] constructorArguments, + object[] testMethodArguments, + IMessageBus messageBus, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + : base(testCase, displayName, skipReason, constructorArguments, testMethodArguments, messageBus, aggregator, cancellationTokenSource) + { + } + + protected override XunitTestRunner CreateTestRunner( + ITest test, + IMessageBus messageBus, + Type testClass, + object[] constructorArguments, + MethodInfo testMethod, + object[] testMethodArguments, + string skipReason, + IReadOnlyList beforeAfterAttributes, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + => new LoggedTestRunner(test, messageBus, testClass, constructorArguments, testMethod, testMethodArguments, + skipReason, beforeAfterAttributes, new ExceptionAggregator(aggregator), cancellationTokenSource); + } +} diff --git a/src/Logging/Logging.Testing/src/Xunit/LoggedTestClassRunner.cs b/src/Logging/Logging.Testing/src/Xunit/LoggedTestClassRunner.cs new file mode 100644 index 0000000000..e7c30264e6 --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LoggedTestClassRunner.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class LoggedTestClassRunner : XunitTestClassRunner + { + public LoggedTestClassRunner( + ITestClass testClass, + IReflectionTypeInfo @class, + IEnumerable testCases, + IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + ITestCaseOrderer testCaseOrderer, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource, + IDictionary collectionFixtureMappings) + : base(testClass, @class, testCases, diagnosticMessageSink, messageBus, testCaseOrderer, aggregator, cancellationTokenSource, collectionFixtureMappings) + { + } + + protected override Task RunTestMethodAsync( + ITestMethod testMethod, + IReflectionMethodInfo method, + IEnumerable testCases, + object[] constructorArguments) + => new LoggedTestMethodRunner(testMethod, Class, method, testCases, DiagnosticMessageSink, MessageBus, new ExceptionAggregator(Aggregator), CancellationTokenSource, constructorArguments).RunAsync(); + } +} diff --git a/src/Logging/Logging.Testing/src/Xunit/LoggedTestCollectionRunner.cs b/src/Logging/Logging.Testing/src/Xunit/LoggedTestCollectionRunner.cs new file mode 100644 index 0000000000..c3713d4245 --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LoggedTestCollectionRunner.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class LoggedTestCollectionRunner : XunitTestCollectionRunner + { + private readonly IMessageSink _diagnosticMessageSink; + + public LoggedTestCollectionRunner( + ITestCollection testCollection, + IEnumerable testCases, + IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + ITestCaseOrderer testCaseOrderer, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + : base(testCollection, testCases, diagnosticMessageSink, messageBus, testCaseOrderer, aggregator, cancellationTokenSource) + { + // Base class doesn't expose this, so capture it here. + _diagnosticMessageSink = diagnosticMessageSink; + } + + protected override Task RunTestClassAsync(ITestClass testClass, IReflectionTypeInfo @class, IEnumerable testCases) + => new LoggedTestClassRunner(testClass, @class, testCases, _diagnosticMessageSink, MessageBus, TestCaseOrderer, new ExceptionAggregator(Aggregator), CancellationTokenSource, CollectionFixtureMappings).RunAsync(); + } +} diff --git a/src/Logging/Logging.Testing/src/Xunit/LoggedTestFramework.cs b/src/Logging/Logging.Testing/src/Xunit/LoggedTestFramework.cs new file mode 100644 index 0000000000..dc5737d32b --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LoggedTestFramework.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Reflection; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class LoggedTestFramework : XunitTestFramework + { + public LoggedTestFramework(IMessageSink messageSink) : base(messageSink) + { + } + + protected override ITestFrameworkDiscoverer CreateDiscoverer(IAssemblyInfo assemblyInfo) + { + return new LoggedTestFrameworkDiscoverer(assemblyInfo, SourceInformationProvider, DiagnosticMessageSink); + } + + protected override ITestFrameworkExecutor CreateExecutor(AssemblyName assemblyName) + { + return new LoggedTestFrameworkExecutor(assemblyName, SourceInformationProvider, DiagnosticMessageSink); + } + } +} diff --git a/src/Logging/Logging.Testing/src/Xunit/LoggedTestFrameworkDiscoverer.cs b/src/Logging/Logging.Testing/src/Xunit/LoggedTestFrameworkDiscoverer.cs new file mode 100644 index 0000000000..bc02355c87 --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LoggedTestFrameworkDiscoverer.cs @@ -0,0 +1,80 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Testing.xunit; +using Xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class LoggedTestFrameworkDiscoverer : XunitTestFrameworkDiscoverer + { + private IDictionary Discoverers { get; } + + public LoggedTestFrameworkDiscoverer( + IAssemblyInfo assemblyInfo, + ISourceInformationProvider sourceProvider, + IMessageSink diagnosticMessageSink, + IXunitTestCollectionFactory collectionFactory = null) + : base(assemblyInfo, sourceProvider, diagnosticMessageSink, collectionFactory) + { + Discoverers = new Dictionary() + { + { typeof(ConditionalTheoryAttribute), new LoggedConditionalTheoryDiscoverer(diagnosticMessageSink) }, + { typeof(ConditionalFactAttribute), new LoggedConditionalFactDiscoverer(diagnosticMessageSink) }, + { typeof(TheoryAttribute), new LoggedTheoryDiscoverer(diagnosticMessageSink) }, + { typeof(FactAttribute), new LoggedFactDiscoverer(diagnosticMessageSink) } + }; + } + + protected override bool FindTestsForMethod( + ITestMethod testMethod, + bool includeSourceInformation, + IMessageBus messageBus, + ITestFrameworkDiscoveryOptions discoveryOptions) + { + if (typeof(LoggedTest).IsAssignableFrom(testMethod.TestClass.Class.ToRuntimeType())) + { + var factAttributes = testMethod.Method.GetCustomAttributes(typeof(FactAttribute)); + if (factAttributes.Count() > 1) + { + var message = $"Test method '{testMethod.TestClass.Class.Name}.{testMethod.Method.Name}' has multiple [Fact]-derived attributes"; + var testCase = new ExecutionErrorTestCase(DiagnosticMessageSink, TestMethodDisplay.ClassAndMethod, testMethod, message); + return ReportDiscoveredTestCase(testCase, includeSourceInformation, messageBus); + } + + var factAttribute = factAttributes.FirstOrDefault(); + if (factAttribute == null) + { + return true; + } + + var factAttributeType = (factAttribute as IReflectionAttributeInfo)?.Attribute.GetType(); + if (!Discoverers.TryGetValue(factAttributeType, out var discoverer)) + { + return base.FindTestsForMethod(testMethod, includeSourceInformation, messageBus, discoveryOptions); + } + else + { + foreach (var testCase in discoverer.Discover(discoveryOptions, testMethod, factAttribute)) + { + if (!ReportDiscoveredTestCase(testCase, includeSourceInformation, messageBus)) + { + return false; + } + } + + return true; + } + } + else + { + return base.FindTestsForMethod(testMethod, includeSourceInformation, messageBus, discoveryOptions); + } + } + } +} \ No newline at end of file diff --git a/src/Logging/Logging.Testing/src/Xunit/LoggedTestFrameworkExecutor.cs b/src/Logging/Logging.Testing/src/Xunit/LoggedTestFrameworkExecutor.cs new file mode 100644 index 0000000000..ece623fa7a --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LoggedTestFrameworkExecutor.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Reflection; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class LoggedTestFrameworkExecutor : XunitTestFrameworkExecutor + { + public LoggedTestFrameworkExecutor(AssemblyName assemblyName, ISourceInformationProvider sourceInformationProvider, IMessageSink diagnosticMessageSink) + : base(assemblyName, sourceInformationProvider, diagnosticMessageSink) + { + } + + protected override async void RunTestCases(IEnumerable testCases, IMessageSink executionMessageSink, ITestFrameworkExecutionOptions executionOptions) + { + using (var assemblyRunner = new LoggedTestAssemblyRunner(TestAssembly, testCases, DiagnosticMessageSink, executionMessageSink, executionOptions)) + { + await assemblyRunner.RunAsync(); + } + } + } +} diff --git a/src/Logging/Logging.Testing/src/Xunit/LoggedTestInvoker.cs b/src/Logging/Logging.Testing/src/Xunit/LoggedTestInvoker.cs new file mode 100644 index 0000000000..09fe03c2b7 --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LoggedTestInvoker.cs @@ -0,0 +1,100 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class LoggedTestInvoker : XunitTestInvoker + { + private TestOutputHelper _output; + + public LoggedTestInvoker( + ITest test, + IMessageBus messageBus, + Type testClass, + object[] constructorArguments, + MethodInfo testMethod, + object[] testMethodArguments, + IReadOnlyList beforeAfterAttributes, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + : base(test, messageBus, testClass, constructorArguments, testMethod, testMethodArguments, beforeAfterAttributes, aggregator, cancellationTokenSource) + { + } + + protected override Task BeforeTestMethodInvokedAsync() + { + if (_output != null) + { + _output.Initialize(MessageBus, Test); + } + + return base.BeforeTestMethodInvokedAsync(); + } + + protected override async Task AfterTestMethodInvokedAsync() + { + await base.AfterTestMethodInvokedAsync(); + + if (_output != null) + { + _output.Uninitialize(); + } + } + + protected override object CreateTestClass() + { + var testClass = base.CreateTestClass(); + + if (testClass is LoggedTest loggedTestClass) + { + var classType = loggedTestClass.GetType(); + var logLevelAttribute = TestMethod.GetCustomAttribute() as LogLevelAttribute; + var testName = TestMethodArguments.Aggregate(TestMethod.Name, (a, b) => $"{a}-{(b ?? "null")}"); + + // Try resolving ITestOutputHelper from constructor arguments + loggedTestClass.TestOutputHelper = ConstructorArguments?.SingleOrDefault(a => typeof(ITestOutputHelper).IsAssignableFrom(a.GetType())) as ITestOutputHelper; + + var useShortClassName = TestMethod.DeclaringType.GetCustomAttribute() + ?? TestMethod.DeclaringType.Assembly.GetCustomAttribute(); + var resolvedClassName = useShortClassName == null ? classType.FullName : classType.Name; + // None resolved so create a new one and retain a reference to it for initialization/uninitialization + if (loggedTestClass.TestOutputHelper == null) + { + loggedTestClass.TestOutputHelper = _output = new TestOutputHelper(); + } + + AssemblyTestLog + .ForAssembly(classType.GetTypeInfo().Assembly) + .StartTestLog( + loggedTestClass.TestOutputHelper, + resolvedClassName, + out var loggerFactory, + logLevelAttribute?.LogLevel ?? LogLevel.Trace, + out var resolvedTestName, + testName); + + // internal for testing + loggedTestClass.ResolvedTestMethodName = resolvedTestName; + loggedTestClass.ResolvedTestClassName = resolvedClassName; + + loggedTestClass.LoggerFactory = loggerFactory; + loggedTestClass.Logger = loggerFactory.CreateLogger(classType); + loggedTestClass.TestSink = new TestSink(); + loggerFactory.AddProvider(new TestLoggerProvider(loggedTestClass.TestSink)); + + loggedTestClass.AdditionalSetup(); + } + + return testClass; + } + } +} diff --git a/src/Logging/Logging.Testing/src/Xunit/LoggedTestMethodRunner.cs b/src/Logging/Logging.Testing/src/Xunit/LoggedTestMethodRunner.cs new file mode 100644 index 0000000000..2ba2988257 --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LoggedTestMethodRunner.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class LoggedTestMethodRunner : XunitTestMethodRunner + { + private IMessageSink DiagnosticMessageSink { get; } + private object[] ConstructorArguments { get; } + + public LoggedTestMethodRunner( + ITestMethod testMethod, + IReflectionTypeInfo @class, + IReflectionMethodInfo method, + IEnumerable testCases, + IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource, + object[] constructorArguments) + : base(testMethod, @class, method, testCases, diagnosticMessageSink, messageBus, aggregator, cancellationTokenSource, constructorArguments) + { + DiagnosticMessageSink = diagnosticMessageSink; + ConstructorArguments = constructorArguments; + } + + protected override Task RunTestCaseAsync(IXunitTestCase testCase) + => testCase.RunAsync(DiagnosticMessageSink, MessageBus, ConstructorArguments, new ExceptionAggregator(Aggregator), CancellationTokenSource); + } +} diff --git a/src/Logging/Logging.Testing/src/Xunit/LoggedTestRunner.cs b/src/Logging/Logging.Testing/src/Xunit/LoggedTestRunner.cs new file mode 100644 index 0000000000..07ad0978d2 --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LoggedTestRunner.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class LoggedTestRunner : XunitTestRunner + { + public LoggedTestRunner( + ITest test, + IMessageBus messageBus, + Type testClass, + object[] constructorArguments, + MethodInfo testMethod, object[] + testMethodArguments, string skipReason, + IReadOnlyList beforeAfterAttributes, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + : base(test, messageBus, testClass, constructorArguments, testMethod, testMethodArguments, skipReason, beforeAfterAttributes, aggregator, cancellationTokenSource) + { + } + + protected override Task InvokeTestMethodAsync(ExceptionAggregator aggregator) + => new LoggedTestInvoker(Test, MessageBus, TestClass, ConstructorArguments, TestMethod, TestMethodArguments, BeforeAfterAttributes, aggregator, CancellationTokenSource).RunAsync(); + } +} diff --git a/src/Logging/Logging.Testing/src/Xunit/LoggedTheoryDiscoverer.cs b/src/Logging/Logging.Testing/src/Xunit/LoggedTheoryDiscoverer.cs new file mode 100644 index 0000000000..535099c690 --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LoggedTheoryDiscoverer.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class LoggedTheoryDiscoverer : TheoryDiscoverer + { + public LoggedTheoryDiscoverer(IMessageSink diagnosticMessageSink) : base(diagnosticMessageSink) + { + } + + protected override IEnumerable CreateTestCasesForDataRow( + ITestFrameworkDiscoveryOptions discoveryOptions, + ITestMethod testMethod, + IAttributeInfo theoryAttribute, + object[] dataRow) + => new[] { new LoggedTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), testMethod, dataRow) }; + + protected override IEnumerable CreateTestCasesForTheory( + ITestFrameworkDiscoveryOptions discoveryOptions, + ITestMethod testMethod, + IAttributeInfo theoryAttribute) + => new[] { new LoggedTheoryTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), testMethod) }; + } +} diff --git a/src/Logging/Logging.Testing/src/Xunit/LoggedTheoryTestCase.cs b/src/Logging/Logging.Testing/src/Xunit/LoggedTheoryTestCase.cs new file mode 100644 index 0000000000..db78f093c2 --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LoggedTheoryTestCase.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class LoggedTheoryTestCase : XunitTheoryTestCase + { + [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] + public LoggedTheoryTestCase() : base() + { + } + + public LoggedTheoryTestCase( + IMessageSink diagnosticMessageSink, + TestMethodDisplay defaultMethodDisplay, + ITestMethod testMethod) + : base(diagnosticMessageSink, defaultMethodDisplay, testMethod) + { + } + + public override Task RunAsync( + IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + object[] constructorArguments, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + => new LoggedTheoryTestCaseRunner(this, DisplayName, SkipReason, constructorArguments, diagnosticMessageSink, messageBus, aggregator, cancellationTokenSource).RunAsync(); + } +} diff --git a/src/Logging/Logging.Testing/src/Xunit/LoggedTheoryTestCaseRunner.cs b/src/Logging/Logging.Testing/src/Xunit/LoggedTheoryTestCaseRunner.cs new file mode 100644 index 0000000000..f1f92e1dcd --- /dev/null +++ b/src/Logging/Logging.Testing/src/Xunit/LoggedTheoryTestCaseRunner.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class LoggedTheoryTestCaseRunner : XunitTheoryTestCaseRunner + { + public LoggedTheoryTestCaseRunner( + IXunitTestCase testCase, + string displayName, + string skipReason, + object[] constructorArguments, + IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + : base(testCase, displayName, skipReason, constructorArguments, diagnosticMessageSink, messageBus, aggregator, cancellationTokenSource) + { + } + + protected override XunitTestRunner CreateTestRunner( + ITest test, + IMessageBus messageBus, + Type testClass, + object[] constructorArguments, + MethodInfo testMethod, + object[] testMethodArguments, + string skipReason, + IReadOnlyList beforeAfterAttributes, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + => new LoggedTestRunner(test, messageBus, testClass, constructorArguments, testMethod, testMethodArguments, skipReason, beforeAfterAttributes, new ExceptionAggregator(aggregator), cancellationTokenSource); + } +} diff --git a/src/Logging/Logging.Testing/src/XunitLoggerFactoryExtensions.cs b/src/Logging/Logging.Testing/src/XunitLoggerFactoryExtensions.cs new file mode 100644 index 0000000000..498ef39c35 --- /dev/null +++ b/src/Logging/Logging.Testing/src/XunitLoggerFactoryExtensions.cs @@ -0,0 +1,37 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.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 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; + } + } +} diff --git a/src/Logging/Logging.Testing/src/XunitLoggerProvider.cs b/src/Logging/Logging.Testing/src/XunitLoggerProvider.cs new file mode 100644 index 0000000000..60ca147a30 --- /dev/null +++ b/src/Logging/Logging.Testing/src/XunitLoggerProvider.cs @@ -0,0 +1,116 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Text; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Logging.Testing +{ + public class XunitLoggerProvider : ILoggerProvider + { + private readonly ITestOutputHelper _output; + private readonly LogLevel _minLevel; + + public XunitLoggerProvider(ITestOutputHelper output) + : this(output, LogLevel.Trace) + { + } + + public XunitLoggerProvider(ITestOutputHelper output, LogLevel minLevel) + { + _output = output; + _minLevel = minLevel; + } + + public ILogger CreateLogger(string categoryName) + { + return new XunitLogger(_output, categoryName, _minLevel); + } + + 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; + + public XunitLogger(ITestOutputHelper output, string category, LogLevel minLogLevel) + { + _minLogLevel = minLogLevel; + _category = category; + _output = output; + } + + 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 = DateTime.Now.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/Logging/Logging.Testing/src/baseline.netcore.json b/src/Logging/Logging.Testing/src/baseline.netcore.json new file mode 100644 index 0000000000..95ba2ad582 --- /dev/null +++ b/src/Logging/Logging.Testing/src/baseline.netcore.json @@ -0,0 +1,1321 @@ +{ + "AssemblyIdentity": "Microsoft.Extensions.Logging.Testing, Version=2.0.1.0, Culture=neutral, PublicKeyToken=adb9793829ddae60", + "Types": [ + { + "Name": "Microsoft.Extensions.Logging.XunitLoggerFactoryExtensions", + "Visibility": "Public", + "Kind": "Class", + "Abstract": true, + "Static": true, + "Sealed": true, + "ImplementedInterfaces": [], + "Members": [ + { + "Kind": "Method", + "Name": "AddXunit", + "Parameters": [ + { + "Name": "builder", + "Type": "Microsoft.Extensions.Logging.ILoggingBuilder" + }, + { + "Name": "output", + "Type": "Xunit.Abstractions.ITestOutputHelper" + } + ], + "ReturnType": "Microsoft.Extensions.Logging.ILoggingBuilder", + "Static": true, + "Extension": true, + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "AddXunit", + "Parameters": [ + { + "Name": "builder", + "Type": "Microsoft.Extensions.Logging.ILoggingBuilder" + }, + { + "Name": "output", + "Type": "Xunit.Abstractions.ITestOutputHelper" + }, + { + "Name": "minLevel", + "Type": "Microsoft.Extensions.Logging.LogLevel" + } + ], + "ReturnType": "Microsoft.Extensions.Logging.ILoggingBuilder", + "Static": true, + "Extension": true, + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "AddXunit", + "Parameters": [ + { + "Name": "loggerFactory", + "Type": "Microsoft.Extensions.Logging.ILoggerFactory" + }, + { + "Name": "output", + "Type": "Xunit.Abstractions.ITestOutputHelper" + } + ], + "ReturnType": "Microsoft.Extensions.Logging.ILoggerFactory", + "Static": true, + "Extension": true, + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "AddXunit", + "Parameters": [ + { + "Name": "loggerFactory", + "Type": "Microsoft.Extensions.Logging.ILoggerFactory" + }, + { + "Name": "output", + "Type": "Xunit.Abstractions.ITestOutputHelper" + }, + { + "Name": "minLevel", + "Type": "Microsoft.Extensions.Logging.LogLevel" + } + ], + "ReturnType": "Microsoft.Extensions.Logging.ILoggerFactory", + "Static": true, + "Extension": true, + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.Extensions.Logging.Testing.AssemblyTestLog", + "Visibility": "Public", + "Kind": "Class", + "ImplementedInterfaces": [ + "System.IDisposable" + ], + "Members": [ + { + "Kind": "Method", + "Name": "StartTestLog", + "Parameters": [ + { + "Name": "output", + "Type": "Xunit.Abstractions.ITestOutputHelper" + }, + { + "Name": "className", + "Type": "System.String" + }, + { + "Name": "loggerFactory", + "Type": "Microsoft.Extensions.Logging.ILoggerFactory", + "Direction": "Out" + }, + { + "Name": "testName", + "Type": "System.String", + "DefaultValue": "null" + } + ], + "ReturnType": "System.IDisposable", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "CreateLoggerFactory", + "Parameters": [ + { + "Name": "output", + "Type": "Xunit.Abstractions.ITestOutputHelper" + }, + { + "Name": "className", + "Type": "System.String" + }, + { + "Name": "testName", + "Type": "System.String", + "DefaultValue": "null" + } + ], + "ReturnType": "Microsoft.Extensions.Logging.ILoggerFactory", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "Create", + "Parameters": [ + { + "Name": "assemblyName", + "Type": "System.String" + }, + { + "Name": "baseDirectory", + "Type": "System.String" + } + ], + "ReturnType": "Microsoft.Extensions.Logging.Testing.AssemblyTestLog", + "Static": true, + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "ForAssembly", + "Parameters": [ + { + "Name": "assembly", + "Type": "System.Reflection.Assembly" + } + ], + "ReturnType": "Microsoft.Extensions.Logging.Testing.AssemblyTestLog", + "Static": true, + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "Dispose", + "Parameters": [], + "ReturnType": "System.Void", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "System.IDisposable", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Field", + "Name": "OutputDirectoryEnvironmentVariableName", + "Parameters": [], + "ReturnType": "System.String", + "Static": true, + "ReadOnly": true, + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.Extensions.Logging.Testing.BeginScopeContext", + "Visibility": "Public", + "Kind": "Class", + "ImplementedInterfaces": [], + "Members": [ + { + "Kind": "Method", + "Name": "get_Scope", + "Parameters": [], + "ReturnType": "System.Object", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_Scope", + "Parameters": [ + { + "Name": "value", + "Type": "System.Object" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_LoggerName", + "Parameters": [], + "ReturnType": "System.String", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_LoggerName", + "Parameters": [ + { + "Name": "value", + "Type": "System.String" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [], + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.Extensions.Logging.Testing.ITestSink", + "Visibility": "Public", + "Kind": "Interface", + "Abstract": true, + "ImplementedInterfaces": [], + "Members": [ + { + "Kind": "Method", + "Name": "get_WriteEnabled", + "Parameters": [], + "ReturnType": "System.Func", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_WriteEnabled", + "Parameters": [ + { + "Name": "value", + "Type": "System.Func" + } + ], + "ReturnType": "System.Void", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_BeginEnabled", + "Parameters": [], + "ReturnType": "System.Func", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_BeginEnabled", + "Parameters": [ + { + "Name": "value", + "Type": "System.Func" + } + ], + "ReturnType": "System.Void", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_Scopes", + "Parameters": [], + "ReturnType": "System.Collections.Generic.List", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_Scopes", + "Parameters": [ + { + "Name": "value", + "Type": "System.Collections.Generic.List" + } + ], + "ReturnType": "System.Void", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_Writes", + "Parameters": [], + "ReturnType": "System.Collections.Generic.List", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_Writes", + "Parameters": [ + { + "Name": "value", + "Type": "System.Collections.Generic.List" + } + ], + "ReturnType": "System.Void", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "Write", + "Parameters": [ + { + "Name": "context", + "Type": "Microsoft.Extensions.Logging.Testing.WriteContext" + } + ], + "ReturnType": "System.Void", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "Begin", + "Parameters": [ + { + "Name": "context", + "Type": "Microsoft.Extensions.Logging.Testing.BeginScopeContext" + } + ], + "ReturnType": "System.Void", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.Extensions.Logging.Testing.LoggedTest", + "Visibility": "Public", + "Kind": "Class", + "Abstract": true, + "ImplementedInterfaces": [], + "Members": [ + { + "Kind": "Method", + "Name": "StartLog", + "Parameters": [ + { + "Name": "loggerFactory", + "Type": "Microsoft.Extensions.Logging.ILoggerFactory", + "Direction": "Out" + }, + { + "Name": "testName", + "Type": "System.String", + "DefaultValue": "null" + } + ], + "ReturnType": "System.IDisposable", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [ + { + "Name": "output", + "Type": "Xunit.Abstractions.ITestOutputHelper" + } + ], + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.Extensions.Logging.Testing.LogValuesAssert", + "Visibility": "Public", + "Kind": "Class", + "Abstract": true, + "Static": true, + "Sealed": true, + "ImplementedInterfaces": [], + "Members": [ + { + "Kind": "Method", + "Name": "Contains", + "Parameters": [ + { + "Name": "key", + "Type": "System.String" + }, + { + "Name": "value", + "Type": "System.Object" + }, + { + "Name": "actualValues", + "Type": "System.Collections.Generic.IEnumerable>" + } + ], + "ReturnType": "System.Void", + "Static": true, + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "Contains", + "Parameters": [ + { + "Name": "expectedValues", + "Type": "System.Collections.Generic.IEnumerable>" + }, + { + "Name": "actualValues", + "Type": "System.Collections.Generic.IEnumerable>" + } + ], + "ReturnType": "System.Void", + "Static": true, + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.Extensions.Logging.Testing.TestLogger", + "Visibility": "Public", + "Kind": "Class", + "ImplementedInterfaces": [ + "Microsoft.Extensions.Logging.ILogger" + ], + "Members": [ + { + "Kind": "Method", + "Name": "get_Name", + "Parameters": [], + "ReturnType": "System.String", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_Name", + "Parameters": [ + { + "Name": "value", + "Type": "System.String" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "BeginScope", + "Parameters": [ + { + "Name": "state", + "Type": "T0" + } + ], + "ReturnType": "System.IDisposable", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.ILogger", + "Visibility": "Public", + "GenericParameter": [ + { + "ParameterName": "TState", + "ParameterPosition": 0, + "BaseTypeOrInterfaces": [] + } + ] + }, + { + "Kind": "Method", + "Name": "Log", + "Parameters": [ + { + "Name": "logLevel", + "Type": "Microsoft.Extensions.Logging.LogLevel" + }, + { + "Name": "eventId", + "Type": "Microsoft.Extensions.Logging.EventId" + }, + { + "Name": "state", + "Type": "T0" + }, + { + "Name": "exception", + "Type": "System.Exception" + }, + { + "Name": "formatter", + "Type": "System.Func" + } + ], + "ReturnType": "System.Void", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.ILogger", + "Visibility": "Public", + "GenericParameter": [ + { + "ParameterName": "TState", + "ParameterPosition": 0, + "BaseTypeOrInterfaces": [] + } + ] + }, + { + "Kind": "Method", + "Name": "IsEnabled", + "Parameters": [ + { + "Name": "logLevel", + "Type": "Microsoft.Extensions.Logging.LogLevel" + } + ], + "ReturnType": "System.Boolean", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.ILogger", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [ + { + "Name": "name", + "Type": "System.String" + }, + { + "Name": "sink", + "Type": "Microsoft.Extensions.Logging.Testing.ITestSink" + }, + { + "Name": "enabled", + "Type": "System.Boolean" + } + ], + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [ + { + "Name": "name", + "Type": "System.String" + }, + { + "Name": "sink", + "Type": "Microsoft.Extensions.Logging.Testing.ITestSink" + }, + { + "Name": "filter", + "Type": "System.Func" + } + ], + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.Extensions.Logging.Testing.TestLoggerFactory", + "Visibility": "Public", + "Kind": "Class", + "ImplementedInterfaces": [ + "Microsoft.Extensions.Logging.ILoggerFactory" + ], + "Members": [ + { + "Kind": "Method", + "Name": "CreateLogger", + "Parameters": [ + { + "Name": "name", + "Type": "System.String" + } + ], + "ReturnType": "Microsoft.Extensions.Logging.ILogger", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.ILoggerFactory", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "AddProvider", + "Parameters": [ + { + "Name": "provider", + "Type": "Microsoft.Extensions.Logging.ILoggerProvider" + } + ], + "ReturnType": "System.Void", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.ILoggerFactory", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [ + { + "Name": "sink", + "Type": "Microsoft.Extensions.Logging.Testing.ITestSink" + }, + { + "Name": "enabled", + "Type": "System.Boolean" + } + ], + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.Extensions.Logging.Testing.TestLogger", + "Visibility": "Public", + "Kind": "Class", + "ImplementedInterfaces": [ + "Microsoft.Extensions.Logging.ILogger" + ], + "Members": [ + { + "Kind": "Method", + "Name": "BeginScope", + "Parameters": [ + { + "Name": "state", + "Type": "T0" + } + ], + "ReturnType": "System.IDisposable", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.ILogger", + "Visibility": "Public", + "GenericParameter": [ + { + "ParameterName": "TState", + "ParameterPosition": 0, + "BaseTypeOrInterfaces": [] + } + ] + }, + { + "Kind": "Method", + "Name": "IsEnabled", + "Parameters": [ + { + "Name": "logLevel", + "Type": "Microsoft.Extensions.Logging.LogLevel" + } + ], + "ReturnType": "System.Boolean", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.ILogger", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "Log", + "Parameters": [ + { + "Name": "logLevel", + "Type": "Microsoft.Extensions.Logging.LogLevel" + }, + { + "Name": "eventId", + "Type": "Microsoft.Extensions.Logging.EventId" + }, + { + "Name": "state", + "Type": "T0" + }, + { + "Name": "exception", + "Type": "System.Exception" + }, + { + "Name": "formatter", + "Type": "System.Func" + } + ], + "ReturnType": "System.Void", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.ILogger", + "Visibility": "Public", + "GenericParameter": [ + { + "ParameterName": "TState", + "ParameterPosition": 0, + "BaseTypeOrInterfaces": [] + } + ] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [ + { + "Name": "factory", + "Type": "Microsoft.Extensions.Logging.Testing.TestLoggerFactory" + } + ], + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [ + { + "ParameterName": "T", + "ParameterPosition": 0, + "BaseTypeOrInterfaces": [] + } + ] + }, + { + "Name": "Microsoft.Extensions.Logging.Testing.TestSink", + "Visibility": "Public", + "Kind": "Class", + "ImplementedInterfaces": [ + "Microsoft.Extensions.Logging.Testing.ITestSink" + ], + "Members": [ + { + "Kind": "Method", + "Name": "get_WriteEnabled", + "Parameters": [], + "ReturnType": "System.Func", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.Testing.ITestSink", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_WriteEnabled", + "Parameters": [ + { + "Name": "value", + "Type": "System.Func" + } + ], + "ReturnType": "System.Void", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.Testing.ITestSink", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_BeginEnabled", + "Parameters": [], + "ReturnType": "System.Func", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.Testing.ITestSink", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_BeginEnabled", + "Parameters": [ + { + "Name": "value", + "Type": "System.Func" + } + ], + "ReturnType": "System.Void", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.Testing.ITestSink", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_Scopes", + "Parameters": [], + "ReturnType": "System.Collections.Generic.List", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.Testing.ITestSink", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_Scopes", + "Parameters": [ + { + "Name": "value", + "Type": "System.Collections.Generic.List" + } + ], + "ReturnType": "System.Void", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.Testing.ITestSink", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_Writes", + "Parameters": [], + "ReturnType": "System.Collections.Generic.List", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.Testing.ITestSink", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_Writes", + "Parameters": [ + { + "Name": "value", + "Type": "System.Collections.Generic.List" + } + ], + "ReturnType": "System.Void", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.Testing.ITestSink", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "Write", + "Parameters": [ + { + "Name": "context", + "Type": "Microsoft.Extensions.Logging.Testing.WriteContext" + } + ], + "ReturnType": "System.Void", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.Testing.ITestSink", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "Begin", + "Parameters": [ + { + "Name": "context", + "Type": "Microsoft.Extensions.Logging.Testing.BeginScopeContext" + } + ], + "ReturnType": "System.Void", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.Testing.ITestSink", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "EnableWithTypeName", + "Parameters": [ + { + "Name": "context", + "Type": "Microsoft.Extensions.Logging.Testing.WriteContext" + } + ], + "ReturnType": "System.Boolean", + "Static": true, + "Visibility": "Public", + "GenericParameter": [ + { + "ParameterName": "T", + "ParameterPosition": 0, + "BaseTypeOrInterfaces": [] + } + ] + }, + { + "Kind": "Method", + "Name": "EnableWithTypeName", + "Parameters": [ + { + "Name": "context", + "Type": "Microsoft.Extensions.Logging.Testing.BeginScopeContext" + } + ], + "ReturnType": "System.Boolean", + "Static": true, + "Visibility": "Public", + "GenericParameter": [ + { + "ParameterName": "T", + "ParameterPosition": 0, + "BaseTypeOrInterfaces": [] + } + ] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [ + { + "Name": "writeEnabled", + "Type": "System.Func", + "DefaultValue": "null" + }, + { + "Name": "beginEnabled", + "Type": "System.Func", + "DefaultValue": "null" + } + ], + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.Extensions.Logging.Testing.WriteContext", + "Visibility": "Public", + "Kind": "Class", + "ImplementedInterfaces": [], + "Members": [ + { + "Kind": "Method", + "Name": "get_LogLevel", + "Parameters": [], + "ReturnType": "Microsoft.Extensions.Logging.LogLevel", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_LogLevel", + "Parameters": [ + { + "Name": "value", + "Type": "Microsoft.Extensions.Logging.LogLevel" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_EventId", + "Parameters": [], + "ReturnType": "Microsoft.Extensions.Logging.EventId", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_EventId", + "Parameters": [ + { + "Name": "value", + "Type": "Microsoft.Extensions.Logging.EventId" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_State", + "Parameters": [], + "ReturnType": "System.Object", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_State", + "Parameters": [ + { + "Name": "value", + "Type": "System.Object" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_Exception", + "Parameters": [], + "ReturnType": "System.Exception", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_Exception", + "Parameters": [ + { + "Name": "value", + "Type": "System.Exception" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_Formatter", + "Parameters": [], + "ReturnType": "System.Func", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_Formatter", + "Parameters": [ + { + "Name": "value", + "Type": "System.Func" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_Scope", + "Parameters": [], + "ReturnType": "System.Object", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_Scope", + "Parameters": [ + { + "Name": "value", + "Type": "System.Object" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "get_LoggerName", + "Parameters": [], + "ReturnType": "System.String", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "set_LoggerName", + "Parameters": [ + { + "Name": "value", + "Type": "System.String" + } + ], + "ReturnType": "System.Void", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [], + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.Extensions.Logging.Testing.XunitLoggerProvider", + "Visibility": "Public", + "Kind": "Class", + "ImplementedInterfaces": [ + "Microsoft.Extensions.Logging.ILoggerProvider" + ], + "Members": [ + { + "Kind": "Method", + "Name": "CreateLogger", + "Parameters": [ + { + "Name": "categoryName", + "Type": "System.String" + } + ], + "ReturnType": "Microsoft.Extensions.Logging.ILogger", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.ILoggerProvider", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [ + { + "Name": "output", + "Type": "Xunit.Abstractions.ITestOutputHelper" + } + ], + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [ + { + "Name": "output", + "Type": "Xunit.Abstractions.ITestOutputHelper" + }, + { + "Name": "minLevel", + "Type": "Microsoft.Extensions.Logging.LogLevel" + } + ], + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + }, + { + "Name": "Microsoft.Extensions.Logging.Testing.XunitLogger", + "Visibility": "Public", + "Kind": "Class", + "ImplementedInterfaces": [ + "Microsoft.Extensions.Logging.ILogger" + ], + "Members": [ + { + "Kind": "Method", + "Name": "Log", + "Parameters": [ + { + "Name": "logLevel", + "Type": "Microsoft.Extensions.Logging.LogLevel" + }, + { + "Name": "eventId", + "Type": "Microsoft.Extensions.Logging.EventId" + }, + { + "Name": "state", + "Type": "T0" + }, + { + "Name": "exception", + "Type": "System.Exception" + }, + { + "Name": "formatter", + "Type": "System.Func" + } + ], + "ReturnType": "System.Void", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.ILogger", + "Visibility": "Public", + "GenericParameter": [ + { + "ParameterName": "TState", + "ParameterPosition": 0, + "BaseTypeOrInterfaces": [] + } + ] + }, + { + "Kind": "Method", + "Name": "IsEnabled", + "Parameters": [ + { + "Name": "logLevel", + "Type": "Microsoft.Extensions.Logging.LogLevel" + } + ], + "ReturnType": "System.Boolean", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.ILogger", + "Visibility": "Public", + "GenericParameter": [] + }, + { + "Kind": "Method", + "Name": "BeginScope", + "Parameters": [ + { + "Name": "state", + "Type": "T0" + } + ], + "ReturnType": "System.IDisposable", + "Sealed": true, + "Virtual": true, + "ImplementedInterface": "Microsoft.Extensions.Logging.ILogger", + "Visibility": "Public", + "GenericParameter": [ + { + "ParameterName": "TState", + "ParameterPosition": 0, + "BaseTypeOrInterfaces": [] + } + ] + }, + { + "Kind": "Constructor", + "Name": ".ctor", + "Parameters": [ + { + "Name": "output", + "Type": "Xunit.Abstractions.ITestOutputHelper" + }, + { + "Name": "category", + "Type": "System.String" + }, + { + "Name": "minLogLevel", + "Type": "Microsoft.Extensions.Logging.LogLevel" + } + ], + "Visibility": "Public", + "GenericParameter": [] + } + ], + "GenericParameters": [] + } + ] +} \ No newline at end of file diff --git a/src/Logging/Logging.Testing/src/build/Microsoft.Extensions.Logging.Testing.props b/src/Logging/Logging.Testing/src/build/Microsoft.Extensions.Logging.Testing.props new file mode 100644 index 0000000000..f98e3e13b5 --- /dev/null +++ b/src/Logging/Logging.Testing/src/build/Microsoft.Extensions.Logging.Testing.props @@ -0,0 +1,8 @@ + + + + <_Parameter1>Microsoft.Extensions.Logging.Testing.LoggedTestFramework + <_Parameter2>Microsoft.Extensions.Logging.Testing + + + \ No newline at end of file diff --git a/src/Logging/Logging.Testing/test/AssemblyTestLogTests.cs b/src/Logging/Logging.Testing/test/AssemblyTestLogTests.cs new file mode 100644 index 0000000000..0efadb4367 --- /dev/null +++ b/src/Logging/Logging.Testing/test/AssemblyTestLogTests.cs @@ -0,0 +1,207 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.Logging.Testing.Tests +{ + public class AssemblyTestLogTests : LoggedTest + { + private static readonly Assembly ThisAssembly = typeof(AssemblyTestLog).GetTypeInfo().Assembly; + + [Fact] + public void FullClassNameUsedWhenShortClassNameAttributeNotSpecified() + { + Assert.Equal(GetType().FullName, ResolvedTestClassName); + } + + [Fact] + public void ForAssembly_ReturnsSameInstanceForSameAssembly() + { + Assert.Same( + AssemblyTestLog.ForAssembly(ThisAssembly), + AssemblyTestLog.ForAssembly(ThisAssembly)); + } + + [Fact] + public void TestLogWritesToITestOutputHelper() + { + var output = new TestTestOutputHelper(); + var assemblyLog = AssemblyTestLog.Create("NonExistant.Test.Assembly", baseDirectory: null); + + using (assemblyLog.StartTestLog(output, "NonExistant.Test.Class", out var loggerFactory)) + { + var logger = loggerFactory.CreateLogger("TestLogger"); + logger.LogInformation("Information!"); + + // Trace is disabled by default + logger.LogTrace("Trace!"); + } + + Assert.Equal(@"[TIMESTAMP] TestLifetime Information: Starting test TestLogWritesToITestOutputHelper +[TIMESTAMP] TestLogger Information: Information! +[TIMESTAMP] TestLifetime Information: Finished test TestLogWritesToITestOutputHelper in DURATION +", MakeConsistent(output.Output), ignoreLineEndingDifferences: true); + } + + [Fact] + private Task TestLogEscapesIllegalFileNames() => + RunTestLogFunctionalTest((tempDir) => + { + var illegalTestName = "Testing-https://localhost:5000"; + var escapedTestName = "Testing-https_localhost_5000"; + using (var testAssemblyLog = AssemblyTestLog.Create("FakeTestAssembly", baseDirectory: tempDir)) + using (testAssemblyLog.StartTestLog(output: null, className: "FakeTestAssembly.FakeTestClass", loggerFactory: out var testLoggerFactory, minLogLevel: LogLevel.Trace, resolvedTestName: out var resolvedTestname, testName: illegalTestName)) + { + Assert.Equal(escapedTestName, resolvedTestname); + } + }); + + [Fact] + public Task TestLogWritesToGlobalLogFile() => + RunTestLogFunctionalTest((tempDir) => + { + // Because this test writes to a file, it is a functional test and should be logged + // but it's also testing the test logging facility. So this is pretty meta ;) + var logger = LoggerFactory.CreateLogger("Test"); + + using (var testAssemblyLog = AssemblyTestLog.Create("FakeTestAssembly", tempDir)) + { + logger.LogInformation("Created test log in {baseDirectory}", tempDir); + + using (testAssemblyLog.StartTestLog(output: null, className: "FakeTestAssembly.FakeTestClass", loggerFactory: out var testLoggerFactory, minLogLevel: LogLevel.Trace, testName: "FakeTestName")) + { + var testLogger = testLoggerFactory.CreateLogger("TestLogger"); + testLogger.LogInformation("Information!"); + testLogger.LogTrace("Trace!"); + } + } + + logger.LogInformation("Finished test log in {baseDirectory}", tempDir); + + var globalLogPath = Path.Combine(tempDir, "FakeTestAssembly", RuntimeInformation.FrameworkDescription.TrimStart('.'), "global.log"); + var testLog = Path.Combine(tempDir, "FakeTestAssembly", RuntimeInformation.FrameworkDescription.TrimStart('.'), "FakeTestClass", $"FakeTestName.log"); + + Assert.True(File.Exists(globalLogPath), $"Expected global log file {globalLogPath} to exist"); + Assert.True(File.Exists(testLog), $"Expected test log file {testLog} to exist"); + + var globalLogContent = MakeConsistent(File.ReadAllText(globalLogPath)); + var testLogContent = MakeConsistent(File.ReadAllText(testLog)); + + Assert.Equal(@"[GlobalTestLog] [Information] Global Test Logging initialized. Set the 'ASPNETCORE_TEST_LOG_DIR' Environment Variable in order to create log files on disk. +[GlobalTestLog] [Information] Starting test ""FakeTestName"" +[GlobalTestLog] [Information] Finished test ""FakeTestName"" in DURATION +", globalLogContent, ignoreLineEndingDifferences: true); + Assert.Equal(@"[TestLifetime] [Information] Starting test ""FakeTestName"" +[TestLogger] [Information] Information! +[TestLogger] [Verbose] Trace! +[TestLifetime] [Information] Finished test ""FakeTestName"" in DURATION +", testLogContent, ignoreLineEndingDifferences: true); + }); + + [Fact] + public Task TestLogTruncatesTestNameToAvoidLongPaths() => + RunTestLogFunctionalTest((tempDir) => + { + var longTestName = new string('0', 50) + new string('1', 50) + new string('2', 50) + new string('3', 50) + new string('4', 50); + var logger = LoggerFactory.CreateLogger("Test"); + using (var testAssemblyLog = AssemblyTestLog.Create("FakeTestAssembly", tempDir)) + { + logger.LogInformation("Created test log in {baseDirectory}", tempDir); + + using (testAssemblyLog.StartTestLog(output: null, className: "FakeTestAssembly.FakeTestClass", loggerFactory: out var testLoggerFactory, minLogLevel: LogLevel.Trace, testName: longTestName)) + { + testLoggerFactory.CreateLogger("TestLogger").LogInformation("Information!"); + } + } + logger.LogInformation("Finished test log in {baseDirectory}", tempDir); + + var testLogFiles = new DirectoryInfo(Path.Combine(tempDir, "FakeTestAssembly", RuntimeInformation.FrameworkDescription.TrimStart('.'), "FakeTestClass")).EnumerateFiles(); + var testLog = Assert.Single(testLogFiles); + var testFileName = Path.GetFileNameWithoutExtension(testLog.Name); + + // The first half of the file comes from the beginning of the test name passed to the logger + Assert.Equal(longTestName.Substring(0, testFileName.Length / 2), testFileName.Substring(0, testFileName.Length / 2)); + // The last half of the file comes from the ending of the test name passed to the logger + Assert.Equal(longTestName.Substring(longTestName.Length - testFileName.Length / 2, testFileName.Length / 2), testFileName.Substring(testFileName.Length - testFileName.Length / 2, testFileName.Length / 2)); + }); + + [Fact] + public Task TestLogEnumerateFilenamesToAvoidCollisions() => + RunTestLogFunctionalTest((tempDir) => + { + var logger = LoggerFactory.CreateLogger("Test"); + using (var testAssemblyLog = AssemblyTestLog.Create("FakeTestAssembly", tempDir)) + { + logger.LogInformation("Created test log in {baseDirectory}", tempDir); + + for (var i = 0; i < 10; i++) + { + using (testAssemblyLog.StartTestLog(output: null, className: "FakeTestAssembly.FakeTestClass", loggerFactory: out var testLoggerFactory, minLogLevel: LogLevel.Trace, testName: "FakeTestName")) + { + testLoggerFactory.CreateLogger("TestLogger").LogInformation("Information!"); + } + } + } + logger.LogInformation("Finished test log in {baseDirectory}", tempDir); + + // The first log file exists + Assert.True(File.Exists(Path.Combine(tempDir, "FakeTestAssembly", RuntimeInformation.FrameworkDescription.TrimStart('.'), "FakeTestClass", $"FakeTestName.log"))); + + // Subsequent files exist + for (var i = 0; i < 9; i++) + { + Assert.True(File.Exists(Path.Combine(tempDir, "FakeTestAssembly", RuntimeInformation.FrameworkDescription.TrimStart('.'), "FakeTestClass", $"FakeTestName.{i}.log"))); + } + }); + + private static readonly Regex TimestampRegex = new Regex(@"\d+-\d+-\d+T\d+:\d+:\d+"); + private static readonly Regex DurationRegex = new Regex(@"[^ ]+s$"); + + private async Task RunTestLogFunctionalTest(Action action, [CallerMemberName] string testName = null) + { + var tempDir = Path.Combine(Path.GetTempPath(), $"TestLogging_{Guid.NewGuid().ToString("N")}"); + try + { + action(tempDir); + } + finally + { + if (Directory.Exists(tempDir)) + { + try + { + Directory.Delete(tempDir, recursive: true); + } + catch + { + await Task.Delay(100); + Directory.Delete(tempDir, recursive: true); + } + } + } + } + + private static string MakeConsistent(string input) + { + return string.Join(Environment.NewLine, input.Split(new[] { Environment.NewLine }, StringSplitOptions.None) + .Select(line => + { + var strippedPrefix = line.IndexOf("[") >= 0 ? line.Substring(line.IndexOf("[")) : line; + + var strippedDuration = + DurationRegex.Replace(strippedPrefix, "DURATION"); + var strippedTimestamp = TimestampRegex.Replace(strippedDuration, "TIMESTAMP"); + return strippedTimestamp; + })); + } + } +} diff --git a/src/Logging/Logging.Testing/test/LogValuesAssertTest.cs b/src/Logging/Logging.Testing/test/LogValuesAssertTest.cs new file mode 100644 index 0000000000..b5e1d98738 --- /dev/null +++ b/src/Logging/Logging.Testing/test/LogValuesAssertTest.cs @@ -0,0 +1,221 @@ +// Copyright(c) .NET Foundation.All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.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/Logging/Logging.Testing/test/LoggedTestXunitTests.cs b/src/Logging/Logging.Testing/test/LoggedTestXunitTests.cs new file mode 100644 index 0000000000..31fd6d631f --- /dev/null +++ b/src/Logging/Logging.Testing/test/LoggedTestXunitTests.cs @@ -0,0 +1,142 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Testing.xunit; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Logging.Testing.Tests +{ + [ShortClassName] + public class LoggedTestXunitTests : TestLoggedTest + { + private readonly ITestOutputHelper _output; + + public LoggedTestXunitTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void ShortClassNameUsedWhenShortClassNameAttributeSpecified() + { + Assert.Equal(GetType().Name, ResolvedTestClassName); + } + + [Fact] + public void LoggedTestTestOutputHelperSameInstanceAsInjectedConstructorArg() + { + Assert.Same(_output, TestOutputHelper); + } + + [Fact] + public void LoggedFactInitializesLoggedTestProperties() + { + Assert.NotNull(Logger); + Assert.NotNull(LoggerFactory); + Assert.NotNull(TestSink); + Assert.NotNull(TestOutputHelper); + } + + [Theory] + [InlineData("Hello world")] + public void LoggedTheoryInitializesLoggedTestProperties(string argument) + { + Assert.NotNull(Logger); + Assert.NotNull(LoggerFactory); + Assert.NotNull(TestSink); + Assert.NotNull(TestOutputHelper); + // Use the test argument + Assert.NotNull(argument); + } + + [ConditionalFact] + public void ConditionalLoggedFactGetsInitializedLoggerFactory() + { + Assert.NotNull(Logger); + Assert.NotNull(LoggerFactory); + Assert.NotNull(TestSink); + Assert.NotNull(TestOutputHelper); + } + + [ConditionalTheory] + [InlineData("Hello world")] + public void LoggedConditionalTheoryInitializesLoggedTestProperties(string argument) + { + Assert.NotNull(Logger); + Assert.NotNull(LoggerFactory); + Assert.NotNull(TestSink); + Assert.NotNull(TestOutputHelper); + // Use the test argument + Assert.NotNull(argument); + } + + [Fact] + [LogLevel(LogLevel.Information)] + public void LoggedFactFilteredByLogLevel() + { + Logger.LogInformation("Information"); + Logger.LogDebug("Debug"); + + var message = Assert.Single(TestSink.Writes); + Assert.Equal(LogLevel.Information, message.LogLevel); + Assert.Equal("Information", message.Formatter(message.State, null)); + } + + [Theory] + [InlineData("Hello world")] + [LogLevel(LogLevel.Information)] + public void LoggedTheoryFilteredByLogLevel(string argument) + { + Logger.LogInformation("Information"); + Logger.LogDebug("Debug"); + + var message = Assert.Single(TestSink.Writes); + Assert.Equal(LogLevel.Information, message.LogLevel); + Assert.Equal("Information", message.Formatter(message.State, null)); + + // Use the test argument + Assert.NotNull(argument); + } + + [Fact] + public void AddTestLoggingUpdatedWhenLoggerFactoryIsSet() + { + var loggerFactory = new LoggerFactory(); + var serviceCollection = new ServiceCollection(); + + LoggerFactory = loggerFactory; + AddTestLogging(serviceCollection); + + Assert.Same(loggerFactory, serviceCollection.BuildServiceProvider().GetRequiredService()); + } + + [ConditionalTheory] + [EnvironmentVariableSkipCondition("ASPNETCORE_TEST_LOG_DIR", "")] // The test name is only generated when logging is enabled via the environment variable + [InlineData(null)] + public void LoggedTheoryNullArgumentsAreEscaped(string argument) + { + Assert.NotNull(LoggerFactory); + Assert.Equal($"{nameof(LoggedTheoryNullArgumentsAreEscaped)}_null", ResolvedTestMethodName); + // Use the test argument + Assert.Null(argument); + } + + [Fact] + public void AdditionalSetupInvoked() + { + Assert.True(SetupInvoked); + } + } + + public class TestLoggedTest : LoggedTest + { + public bool SetupInvoked { get; private set; } = false; + + public override void AdditionalSetup() + { + SetupInvoked = true; + } + } +} diff --git a/src/Logging/Logging.Testing/test/Microsoft.Extensions.Logging.Testing.Tests.csproj b/src/Logging/Logging.Testing/test/Microsoft.Extensions.Logging.Testing.Tests.csproj new file mode 100644 index 0000000000..7d2170b241 --- /dev/null +++ b/src/Logging/Logging.Testing/test/Microsoft.Extensions.Logging.Testing.Tests.csproj @@ -0,0 +1,13 @@ + + + + + $(StandardTestTfms) + + + + + + + + diff --git a/src/Logging/Logging.Testing/test/TestTestOutputHelper.cs b/src/Logging/Logging.Testing/test/TestTestOutputHelper.cs new file mode 100644 index 0000000000..7043fe4ed2 --- /dev/null +++ b/src/Logging/Logging.Testing/test/TestTestOutputHelper.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Text; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Logging.Testing.Tests +{ + public class TestTestOutputHelper : ITestOutputHelper + { + private StringBuilder _output = new StringBuilder(); + + public bool Throw { get; set; } + + public string Output => _output.ToString(); + + public void WriteLine(string message) + { + if (Throw) + { + throw new Exception("Boom!"); + } + _output.AppendLine(message); + } + + public void WriteLine(string format, params object[] args) + { + if (Throw) + { + throw new Exception("Boom!"); + } + _output.AppendLine(string.Format(format, args)); + } + } +} diff --git a/src/Logging/Logging.Testing/test/XunitLoggerProviderTest.cs b/src/Logging/Logging.Testing/test/XunitLoggerProviderTest.cs new file mode 100644 index 0000000000..9720e15a09 --- /dev/null +++ b/src/Logging/Logging.Testing/test/XunitLoggerProviderTest.cs @@ -0,0 +1,87 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Text.RegularExpressions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Test; +using Xunit; + +namespace Microsoft.Extensions.Logging.Testing.Tests +{ + public class XunitLoggerProviderTest + { + [Fact] + public void LoggerProviderWritesToTestOutputHelper() + { + var testTestOutputHelper = new TestTestOutputHelper(); + + var loggerFactory = TestLoggerBuilder.Create(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 = TestLoggerBuilder.Create(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 = TestLoggerBuilder.Create(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 = TestLoggerBuilder.Create(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"); + } +}