Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: Structured Logging Support #141

Open
wants to merge 4 commits into
base: develop/9.0.0
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Arc4u.Diagnostics/Arc4u.Diagnostics.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@
<ItemGroup Condition=" '$(TargetFramework)' == 'net8.0' ">
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'net9.0' ">
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="System.Text.Json" Version="9.0.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.10.0" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\Arc4u.Threading\Arc4u.Threading.csproj" />
</ItemGroup>
</Project>
12 changes: 12 additions & 0 deletions src/Arc4u.Diagnostics/Logging/ApplicationBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Arc4u.Diagnostics.Logging;
using Microsoft.AspNetCore.Builder;

namespace Arc4u.Diagnostics;
public static class ApplicationBuilderExtensions
{
public static IApplicationBuilder UseArc4uLogging(this IApplicationBuilder app)
{
LoggerWrapperContext.Initialize(app.ApplicationServices);
return app;
}
}
20 changes: 20 additions & 0 deletions src/Arc4u.Diagnostics/Logging/HostBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace Arc4u.Diagnostics.Logging;

public static class HostBuilderExtensions
{
public static IHostBuilder UseArc4uLogging(
this IHostBuilder builder,
Action<HostBuilderContext, ILoggingBuilder>? configure = null)
{
builder.ConfigureLogging((context, loggingBuilder) =>
{
var services = loggingBuilder.Services;
configure?.Invoke(context, loggingBuilder);
});

return builder;
}
}
25 changes: 25 additions & 0 deletions src/Arc4u.Diagnostics/Logging/ILoggerExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Arc4u.Diagnostics.Logging;
public static class ILoggerExtensions
{
public static LoggerWrapper<T> Technical<T>(this ILogger logger, [CallerMemberName] string methodName = "") =>
new(logger, MessageCategory.Technical, typeof(T), methodName);

public static LoggerWrapper<T> Business<T>(this ILogger logger, [CallerMemberName] string methodName = "") =>
new(logger, MessageCategory.Business, typeof(T), methodName);

public static LoggerWrapper<T> Monitoring<T>(this ILogger logger, [CallerMemberName] string methodName = "") =>
new(logger, MessageCategory.Monitoring, typeof(T), methodName);

public static LoggerWrapper<T> Technical<T>(this ILogger<T> logger, [CallerMemberName] string methodName = "") =>
new(logger, MessageCategory.Technical, typeof(T), methodName);

public static LoggerWrapper<T> Business<T>(this ILogger<T> logger, [CallerMemberName] string methodName = "") =>
new(logger, MessageCategory.Business, typeof(T), methodName);

public static LoggerWrapper<T> Monitoring<T>(this ILogger<T> logger, [CallerMemberName] string methodName = "") =>
new(logger, MessageCategory.Monitoring, typeof(T), methodName);
}
247 changes: 247 additions & 0 deletions src/Arc4u.Diagnostics/Logging/LoggerWrapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
using System.Reflection;
using System.Text;
using Arc4u.Diagnostics;
using Arc4u.Diagnostics.Logging;
using Microsoft.Extensions.Logging;

namespace Microsoft.Extensions.DependencyInjection;

public sealed class LoggerWrapper<T> : ILogger<T>
{
private readonly ILogger _logger;
private readonly MessageCategory _category;
private readonly Dictionary<string, object?> _additionalFields = [];
private readonly Type? _contextType;
private readonly string _caller = string.Empty;
private bool _disposed;
private bool _includeStackTrace;
private readonly Lazy<IReadOnlyList<IAddPropertiesToLog>> _providers = new(Enumerable.Empty<IAddPropertiesToLog>().ToList());

internal Dictionary<string, object?> AdditionalFields => _additionalFields;
internal bool IncludeStackTrace { get => _includeStackTrace; set => _includeStackTrace = value; }

internal static int ProcessId
{
get
{
try
{
return Environment.ProcessId;
}
catch (PlatformNotSupportedException)
{
return -1;
}
}
}

public LoggerWrapper(ILoggerFactory loggerFactory) =>
_logger = loggerFactory.CreateLogger<T>();

internal LoggerWrapper(
ILogger logger,
MessageCategory category,
Type? contextType = null,
string caller = "")
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_category = category;
_contextType = contextType;
_caller = caller;


_providers = new Lazy<IReadOnlyList<IAddPropertiesToLog>>(() =>
{
using var scope = LoggerWrapperContext.CreateScope();
return scope.ServiceProvider.GetServices<IAddPropertiesToLog>().ToList();
});
}

private static class LoggerMessages
{
private static readonly EventId TechnicalEventId = new(1000, "Technical");
private static readonly EventId BusinessEventId = new(2000, "Business");
private static readonly EventId MonitoringEventId = new(3000, "Monitoring");

public static readonly Func<LogLevel, Action<ILogger, string, string, object, string, Exception?>> TechnicalLog =
level => Logging.LoggerMessage.Define<string, string, object, string>(
level,
TechnicalEventId,
"{Category} [{Context}] [{@State}] {Message}");

public static readonly Func<LogLevel, Action<ILogger, string, string, object, string, Exception?>> BusinessLog =
level => Logging.LoggerMessage.Define<string, string, object, string>(
level,
BusinessEventId,
"{Category} [{Context}] [{@State}] {Message}");

public static readonly Func<LogLevel, Action<ILogger, string, string, object, string, Exception?>> MonitoringLog =
level => Logging.LoggerMessage.Define<string, string, object, string>(
level,
MonitoringEventId,
"{Category} [{Context}] [{@State}] {Message}");
}

private void Log(LogLevel level, string? message, Exception? exception = null)
{
ThrowIfDisposed();

if (!IsEnabled(level))
{
return;
}

try
{
var properties = AddAdditionalProperties();
if (IncludeStackTrace)
{
properties.AddIfNotExist(LoggingConstants.Stacktrace, exception?.StackTrace ?? Environment.StackTrace);
}

if (exception is not null)
{
properties.AddIfNotExist(LoggingConstants.UnwrappedException, exception.ToFormattedstring());
}

properties.AddIfNotExist(LoggingConstants.MethodName, _caller);
properties.AddIfNotExist(LoggingConstants.Class, _contextType?.FullName ?? nameof(_contextType));
properties.AddIfNotExist(LoggingConstants.Category, (short)_category);
properties.AddIfNotExist(LoggingConstants.Application, Assembly.GetEntryAssembly()?.GetName().Name ?? "Unknown App");
properties.AddIfNotExist(LoggingConstants.ThreadId, Environment.CurrentManagedThreadId);
properties.AddIfNotExist(LoggingConstants.ProcessId, ProcessId);

var enrichedState = new
{
Caller = _caller,
TimeStamp = DateTime.UtcNow,
AdditionalFields = properties,
Level = level
};

var logAction = _category switch
{
MessageCategory.Technical => LoggerMessages.TechnicalLog(level),
MessageCategory.Business => LoggerMessages.BusinessLog(level),
MessageCategory.Monitoring => LoggerMessages.MonitoringLog(level),
_ => LoggerMessages.TechnicalLog(level)
};

logAction(
_logger,
_category.ToString(),
_contextType?.Name ?? "General",
enrichedState,
message ?? string.Empty,
exception);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to log message with property providers");
_logger.Log(level, exception, message ?? string.Empty);
}
}

internal void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);

public void Dispose()
{
if (_disposed)
{
return;
}

_disposed = true;
}

private Dictionary<string, object?> AddAdditionalProperties()
{
Dictionary<string, object?> properties;

try
{
properties = new Dictionary<string, object?>(AdditionalFields);
var definedProperties = _providers.Value.Select(x => x.GetProperties()).SelectMany(x => x);
if (definedProperties != null)
{
foreach (var property in definedProperties)
{
if (property.Value != null)
{
this.AddIfNotExist(property.Key, property.Value);
}
}

return properties;
}

return new Dictionary<string, object?>(AdditionalFields);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error getting property providers. Logging without additional properties.");
return new Dictionary<string, object?>(AdditionalFields);
}
}

public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
Console.WriteLine("Log<TState> is called.");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Remove debug Console.WriteLine statement

Debug statements should not be present in production code.

-        Console.WriteLine("Log<TState> is called.");


if (state is IEnumerable<KeyValuePair<string, object>> pairs)
{
foreach (var pair in pairs)
{
AdditionalFields.AddOrReplace(pair.Key, pair.Value);
}
}

Log(logLevel, formatter(state, exception), exception);
}

public bool IsEnabled(LogLevel logLevel) => _logger.IsEnabled(logLevel);

public IDisposable? BeginScope<TState>(TState state) where TState : notnull => _logger.BeginScope(state);
}

internal static class DumpException
{
internal static string ToFormattedstring(this Exception exception)
{
var messages = exception
.GetAllExceptions()
.Where(e => !string.IsNullOrWhiteSpace(e.Message))
.Select(e => e.GetType().FullName + " : " + e.Message.Trim());
var sb = new StringBuilder();
var i = 0;
foreach (var message in messages)
{
sb.Append("".PadLeft(i * 4));
sb.Append("|---".PadLeft(i++ > 0 ? 4 : 0));
sb.AppendLine(message);
}

return sb.ToString();
}

private static IEnumerable<Exception> GetAllExceptions(this Exception exception)
{
yield return exception;

if (exception is AggregateException aggrEx)
{
foreach (var innerEx in aggrEx.InnerExceptions.SelectMany(e => e.GetAllExceptions()))
{
yield return innerEx;
}
}
else if (exception.InnerException != null)
{
foreach (var innerEx in exception.InnerException.GetAllExceptions())
{
yield return innerEx;
}
}
}
}

29 changes: 29 additions & 0 deletions src/Arc4u.Diagnostics/Logging/LoggerWrapperContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Microsoft.Extensions.DependencyInjection;

namespace Arc4u.Diagnostics.Logging;
internal class LoggerWrapperContext
{
private static Lazy<IServiceScopeFactory> _scopeFactory = default!;

/// <summary>
/// Initializes the specified service provider.
/// </summary>
/// <param name="serviceProvider">The service provider.</param>
public static void Initialize(IServiceProvider serviceProvider)
=> _scopeFactory = new Lazy<IServiceScopeFactory>(serviceProvider.GetRequiredService<IServiceScopeFactory>());

/// <summary>
/// Creates the scope.
/// </summary>
/// <returns></returns>
/// <exception cref="InvalidOperationException">LoggerContext not initialized</exception>
public static IServiceScope CreateScope()
{
if (_scopeFactory.IsValueCreated)
{
throw new InvalidOperationException("LoggerContext not initialized");
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Critical: Logic error in CreateScope method

The condition is reversed. The method throws when the factory IS created, but it should throw when it's NOT created.

Apply this fix:

-        if (_scopeFactory.IsValueCreated)
+        if (!_scopeFactory.IsValueCreated)
         {
             throw new InvalidOperationException("LoggerContext not initialized");
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (_scopeFactory.IsValueCreated)
{
throw new InvalidOperationException("LoggerContext not initialized");
}
if (!_scopeFactory.IsValueCreated)
{
throw new InvalidOperationException("LoggerContext not initialized");
}


return _scopeFactory.Value.CreateScope();
}
}
Loading