diff --git a/src/Http/Wolverine.Http.Marten/AggregateAttribute.cs b/src/Http/Wolverine.Http.Marten/AggregateAttribute.cs index a93eae6b2..2a14d1c2c 100644 --- a/src/Http/Wolverine.Http.Marten/AggregateAttribute.cs +++ b/src/Http/Wolverine.Http.Marten/AggregateAttribute.cs @@ -8,8 +8,7 @@ using Marten; using Marten.Events; using Microsoft.AspNetCore.Http; -using Wolverine.Configuration; -using Wolverine.Http.CodeGen; +using Wolverine.Logging; using Wolverine.Marten; using Wolverine.Marten.Codegen; using Wolverine.Marten.Publishing; @@ -78,6 +77,10 @@ public override Variable Modify(HttpChain chain, ParameterInfo parameter, IConta chain.Middleware.Add(new EventStoreFrame()); var loader = typeof(LoadAggregateFrame<>).CloseAndBuildAs(this, AggregateType!); chain.Middleware.Add(loader); + var auditedMembersNotAlreadyPresent = AuditedMember.GetAllFromType(AggregateType).Where(member => !chain.AuditedMembers + .Select(m => m.MemberName) + .Contains(member.MemberName)).ToList(); + chain.Middleware.Add(new LoggerBeginScopeWithAuditForAggregateFrame(chain, container, auditedMembersNotAlreadyPresent, loader.Creates.Single())); // Use the active document session as an IQuerySession instead of creating a new one chain.Method.TrySetArgument(new Variable(typeof(IQuerySession), sessionCreator.ReturnVariable!.Usage)); diff --git a/src/Http/Wolverine.Http/Logging/LoggingPolicies.cs b/src/Http/Wolverine.Http/Logging/LoggingPolicies.cs new file mode 100644 index 000000000..002612025 --- /dev/null +++ b/src/Http/Wolverine.Http/Logging/LoggingPolicies.cs @@ -0,0 +1,39 @@ + +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using Lamar; +using Wolverine.Configuration; +using Wolverine.Logging; + +namespace Wolverine.Http.Logging; + +public class AuditLoggingPolicy : IHttpPolicy +{ + public void Apply(IReadOnlyList chains, GenerationRules rules, IContainer container) + => new Wolverine.Logging.AuditLoggingPolicy().Apply(chains, rules, container); +} + +public class AddConstantsToLoggingContextPolicy : IHttpPolicy +{ + readonly (string, object)[] _loggingConstants; + + public AddConstantsToLoggingContextPolicy(params (string, object)[] loggingConstants) + { + _loggingConstants = loggingConstants; + } + public void Apply(IReadOnlyList chains, GenerationRules rules, IContainer container) + => new Wolverine.Logging.AddConstantsToLoggingContextPolicy(_loggingConstants).Apply(chains, rules, container); +} + +public static class WolverineHttpOptionsExtensions +{ + public static void AddLoggingConstantsFor(this WolverineHttpOptions options, params (string, object)[] kvp) + { + options.Policies.Add(new AddConstantsToLoggingContextPolicy(kvp)); + } + + public static void AddAuditLogging(this WolverineHttpOptions options, params (string, object)[] kvp) + { + options.Policies.Add(new AuditLoggingPolicy()); + } +} diff --git a/src/Persistence/Wolverine.Marten/AggregateHandlerAttribute.cs b/src/Persistence/Wolverine.Marten/AggregateHandlerAttribute.cs index 9a7af2804..5e0e6b5d5 100644 --- a/src/Persistence/Wolverine.Marten/AggregateHandlerAttribute.cs +++ b/src/Persistence/Wolverine.Marten/AggregateHandlerAttribute.cs @@ -11,6 +11,7 @@ using Marten.Schema; using Wolverine.Attributes; using Wolverine.Configuration; +using Wolverine.Logging; using Wolverine.Marten.Codegen; using Wolverine.Marten.Publishing; using Wolverine.Runtime.Handlers; @@ -75,9 +76,7 @@ public override void Modify(IChain chain, GenerationRules rules, IContainer cont AggregateType ??= DetermineAggregateType(chain); AggregateIdMember = DetermineAggregateIdMember(AggregateType, CommandType); VersionMember = DetermineVersionMember(CommandType); - - var sessionCreator = MethodCall.For(x => x.OpenSession(null!)); chain.Middleware.Add(sessionCreator); @@ -89,7 +88,11 @@ public override void Modify(IChain chain, GenerationRules rules, IContainer cont chain.Middleware.Add(new MissingAggregateCheckFrame(AggregateType, CommandType, AggregateIdMember, loader.ReturnVariable!)); } - + var auditedMembersNotAlreadyPresent = AuditedMember.GetAllFromType(AggregateType) + .Where(member => !chain.AuditedMembers + .Select(m => m.MemberName) + .Contains(member.MemberName)).ToList(); + chain.Middleware.Add(new LoggerBeginScopeWithAuditForAggregateFrame(chain, container, auditedMembersNotAlreadyPresent, loader.ReturnVariable!)); // Use the active document session as an IQuerySession instead of creating a new one firstCall.TrySetArgument(new Variable(typeof(IQuerySession), sessionCreator.ReturnVariable!.Usage)); diff --git a/src/Testing/CoreTests/Compilation/handler_with_logged_audit_members.cs b/src/Testing/CoreTests/Compilation/handler_with_logged_audit_members.cs new file mode 100644 index 000000000..e5ce9b162 --- /dev/null +++ b/src/Testing/CoreTests/Compilation/handler_with_logged_audit_members.cs @@ -0,0 +1,50 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TestingSupport; +using Wolverine.Attributes; +using Wolverine.Logging; +using Wolverine.Runtime.Handlers; +using Xunit; +using Xunit.Abstractions; + +namespace CoreTests.Compilation; + +public class handler_with_logged_audit_members +{ + private readonly ITestOutputHelper _output; + + public handler_with_logged_audit_members(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task can_compile() + { + using var host = await WolverineHost.ForAsync(o => o.AddAuditLogging()); + + var bus = host.Services.GetRequiredService(); + await bus.InvokeAsync(new SomeMessage()); + + var graph = host.Services.GetRequiredService(); + var chain = graph.ChainFor(); + + _output.WriteLine(chain.SourceCode); + } +} + +public class SomeMessage +{ + [Audit]public Guid Id { get; set; } = Guid.NewGuid(); + [Audit]public string SomeString { get; set; } = "test"; +} + +public class SomeMessageHandler +{ + public void Handle(SomeMessage itemCreated, ILogger logger) + { + logger.LogInformation("Some message id {Id}", itemCreated.Id); + } +} \ No newline at end of file diff --git a/src/Wolverine/Configuration/Chain.cs b/src/Wolverine/Configuration/Chain.cs index f182b4ff8..c893f263a 100644 --- a/src/Wolverine/Configuration/Chain.cs +++ b/src/Wolverine/Configuration/Chain.cs @@ -62,8 +62,7 @@ public IEnumerable ServiceDependencies(IContainer container, IReadOnlyList /// public void Audit(MemberInfo member, string? heading = null) { - AuditedMembers.Add(new AuditedMember(member, heading ?? member.Name, - member.Name.SplitPascalCase().Replace(" ", ".").ToLowerInvariant())); + AuditedMembers.Add(AuditedMember.Create(member, heading)); } private bool isConfigureMethod(MethodInfo method) @@ -85,20 +84,9 @@ private bool isConfigureMethod(MethodInfo method) protected void applyAuditAttributes(Type type) { - foreach (var property in type.GetProperties()) + foreach (var auditedMember in AuditedMember.GetAllFromType(type)) { - if (property.TryGetAttribute(out var ratt)) - { - Audit(property, ratt.Heading); - } - } - - foreach (var field in type.GetFields()) - { - if (field.TryGetAttribute(out var ratt)) - { - Audit(field, ratt.Heading); - } + AuditedMembers.Add(auditedMember); } } diff --git a/src/Wolverine/Logging/AuditedMember.cs b/src/Wolverine/Logging/AuditedMember.cs index a13ddaea9..b38fd41bd 100644 --- a/src/Wolverine/Logging/AuditedMember.cs +++ b/src/Wolverine/Logging/AuditedMember.cs @@ -1,4 +1,6 @@ using System.Reflection; +using JasperFx.Core.Reflection; +using Wolverine.Attributes; namespace Wolverine.Logging; @@ -8,4 +10,28 @@ namespace Wolverine.Logging; /// /// /// -public record AuditedMember(MemberInfo Member, string MemberName, string OpenTelemetryName); \ No newline at end of file +public record AuditedMember(MemberInfo Member, string MemberName, string OpenTelemetryName) +{ + public static IEnumerable GetAllFromType(Type type) + { + foreach (var property in type.GetProperties()) + { + if (property.TryGetAttribute(out var att)) + { + yield return Create(property, att.Heading); + } + } + + foreach (var field in type.GetFields()) + { + if (field.TryGetAttribute(out var att)) + { + yield return Create(field, att.Heading); + } + } + } + public static AuditedMember Create(MemberInfo member, string? heading) => new( + member, + heading ?? member.Name, + member.Name.ToTelemetryFriendly()); +} \ No newline at end of file diff --git a/src/Wolverine/Logging/LoggerBeginScopeWithAuditFrame.cs b/src/Wolverine/Logging/LoggerBeginScopeWithAuditFrame.cs new file mode 100644 index 000000000..b04b09e9f --- /dev/null +++ b/src/Wolverine/Logging/LoggerBeginScopeWithAuditFrame.cs @@ -0,0 +1,98 @@ +using System.Diagnostics; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Lamar; +using Microsoft.Extensions.Logging; +using Wolverine.Configuration; + +namespace Wolverine.Logging; + +public abstract class LoggerBeginScopeWithAuditBaseFrame : SyncFrame +{ + private readonly Type? _inputType; + private readonly List _loggers = []; + private readonly IEnumerable _loggerTypes; + + protected LoggerBeginScopeWithAuditBaseFrame(IChain chain, IContainer container, IReadOnlyList auditedMembers, Variable? auditedVariable) + { + _loggerTypes = chain.ServiceDependencies(container, Type.EmptyTypes).Where(type => type.CanBeCastTo()); + AuditedMembers = auditedMembers; + _inputType = chain.InputType()!; + AuditedVariable = auditedVariable; + } + + protected Variable? AuditedVariable; + protected Variable? LoggingContext; + protected readonly IReadOnlyList AuditedMembers; + + public override IEnumerable FindVariables(IMethodVariables chain) + { + foreach (var loggerType in _loggerTypes) + { + var logger = chain.FindVariable(loggerType); + _loggers.Add(logger); + yield return logger; + } + LoggingContext = chain.FindVariable(typeof(LoggingContext)); + if (AuditedVariable is not null || _inputType is null) + { + yield break; + } + AuditedVariable = chain.FindVariable(_inputType); + yield return AuditedVariable; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + if (AuditedMembers.Count > 0 && _loggers.Count > 0 && AuditedVariable is not null) + { + writer.WriteComment("Adding audited members to log context"); + writer.Write($"{LoggingContext!.Usage}.{nameof(Logging.LoggingContext.AddRange)}({string.Join(", ", AuditedMembers.Select(member => $"(\"{member.MemberName}\", {AuditedVariable!.Usage}.{member.Member.Name})"))});"); + writer.WriteComment("Beginning logging scopes for new context"); + foreach (var logger in _loggers) + { + writer.Write( + $"using var {createRandomVariable("disposable")} = {logger.Usage}.{nameof(ILogger.BeginScope)}({LoggingContext!.Usage});"); + } + } + GenerateAdditionalCode(method, writer, _loggers); + Next?.GenerateCode(method, writer); + } + + protected virtual void GenerateAdditionalCode(GeneratedMethod method, ISourceWriter writer, IEnumerable loggerVariables) + { + } + static string createRandomVariable(string prefix) => $"{prefix}_{Guid.NewGuid().ToString().Replace('-', '_')}"; +} + +public class LoggerBeginScopeWithAuditFrame : LoggerBeginScopeWithAuditBaseFrame +{ + public LoggerBeginScopeWithAuditFrame(IChain chain, IContainer container) + : base(chain, container, chain.AuditedMembers.AsReadOnly(), null) + { + } +} + +public class LoggerBeginScopeWithAuditForAggregateFrame : LoggerBeginScopeWithAuditBaseFrame +{ + public LoggerBeginScopeWithAuditForAggregateFrame(IChain chain, IContainer container, IReadOnlyList members, Variable variable) + : base(chain, container, members, variable) + { + } + + protected override void GenerateAdditionalCode(GeneratedMethod method, ISourceWriter writer, IEnumerable loggerVariables) + { + if (AuditedMembers.Count == 0) + { + return; + } + writer.WriteComment("Application-specific Open Telemetry auditing"); + foreach (var member in AuditedMembers) + { + writer.WriteLine( + $"{typeof(Activity).FullNameInCode()}.{nameof(Activity.Current)}?.{nameof(Activity.SetTag)}(\"{member.OpenTelemetryName}\", {AuditedVariable!.Usage}.{member.Member.Name});"); + } + } +} diff --git a/src/Wolverine/Logging/LoggingContext.cs b/src/Wolverine/Logging/LoggingContext.cs new file mode 100644 index 000000000..10ee89e2e --- /dev/null +++ b/src/Wolverine/Logging/LoggingContext.cs @@ -0,0 +1,87 @@ +using System.Diagnostics; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Wolverine.Configuration; + +namespace Wolverine.Logging; + +public class LoggingContext : Dictionary +{ + public void AddRange(params (string, object)[] kvp) + { + foreach (var (key, value) in kvp) + { + TryAdd(key, value); + } + } +} + +public class LoggingContextFrame : SyncFrame +{ + private readonly Variable _loggingContext; + public LoggingContextFrame() + { + _loggingContext = Create("wolverineLoggingContext_loggingTools"); + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + yield return _loggingContext; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.Write($"var {_loggingContext.Usage} = new {typeof(LoggingContext).FullName}();"); + Next?.GenerateCode(method, writer); + } +} + +public class AddConstantsToLoggingContextFrame : SyncFrame +{ + private readonly (string, object)[] _loggingConstants; + private Variable _loggingContext; + public AddConstantsToLoggingContextFrame(params (string, object)[] loggingConstants) + { + if (loggingConstants.Any(x => x.Item2 is not string && !x.Item2.GetType().IsNumeric())) + { + throw new ArgumentException("One or more of the constants provided is not a string or a numeric type", nameof(loggingConstants)); + } + _loggingConstants = loggingConstants; + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _loggingContext = chain.FindVariable(typeof(LoggingContext)); + yield break; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.Write($"{_loggingContext!.Usage}.{nameof(LoggingContext.AddRange)}({string.Join(", ", _loggingConstants.Select(kvp => $"(\"{kvp.Item1}\", {GetValue(kvp.Item2)})"))});"); + foreach (var (key, value) in _loggingConstants) + { + writer.WriteLine( + $"{typeof(Activity).FullNameInCode()}.{nameof(Activity.Current)}?.{nameof(Activity.SetTag)}(\"{key.ToTelemetryFriendly()}\", {GetValue(value)});"); + } + Next?.GenerateCode(method, writer); + return; + + static string GetValue(object o) => o is string ? $"\"{o}\"" : o.ToString()!; + } +} + +internal static class Extensions +{ + public static void AddMiddlewareAfterLoggingContextFrame(this IChain chain, params Frame[] frames) + { + var frameIndex = chain.Middleware.FindIndex(f => f is LoggingContextFrame); + if (frameIndex == -1) + { + frameIndex = 0; + chain.Middleware.Insert(frameIndex, new LoggingContextFrame()); + } + chain.Middleware.InsertRange(frameIndex + 1, frames); + } +} \ No newline at end of file diff --git a/src/Wolverine/Logging/LoggingPolicies.cs b/src/Wolverine/Logging/LoggingPolicies.cs new file mode 100644 index 000000000..9279be282 --- /dev/null +++ b/src/Wolverine/Logging/LoggingPolicies.cs @@ -0,0 +1,54 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using Lamar; +using Wolverine.Configuration; + +namespace Wolverine.Logging; + +public class AuditLoggingPolicy : IChainPolicy +{ + public void Apply(IReadOnlyList chains, GenerationRules rules, IContainer container) + { + foreach (var chain in chains) + { + List frames = []; + if (!chain.AuditedMembers.Exists(member => member.MemberName.Equals(TenantIdLogContextFrame.TenantIdContextName)) + && !chain.Middleware.Exists(frame => frame is TenantIdLogContextFrame)) + { + frames.Add(new TenantIdLogContextFrame()); + } + frames.Add(new LoggerBeginScopeWithAuditFrame(chain, container)); + chain.AddMiddlewareAfterLoggingContextFrame(frames.ToArray()); + } + } +} + +public class AddConstantsToLoggingContextPolicy : IChainPolicy +{ + private readonly (string, object)[] _loggingConstants; + + public AddConstantsToLoggingContextPolicy(params (string, object)[] loggingConstants) + { + _loggingConstants = loggingConstants; + } + public void Apply(IReadOnlyList chains, GenerationRules rules, IContainer container) + { + foreach (var chain in chains.Where(chain => typeof(TMessage).IsAssignableFrom(chain.InputType()))) + { + chain.AddMiddlewareAfterLoggingContextFrame(new AddConstantsToLoggingContextFrame(_loggingConstants)); + } + } +} + +public static class WolverineOptionsExtensions +{ + public static void AddLoggingConstantsFor(this WolverineOptions options, params (string, object)[] kvp) + { + options.Policies.Add(new AddConstantsToLoggingContextPolicy(kvp)); + } + + public static void AddAuditLogging(this WolverineOptions options, params (string, object)[] kvp) + { + options.Policies.Add(new AuditLoggingPolicy()); + } +} diff --git a/src/Wolverine/Logging/StringExtensions.cs b/src/Wolverine/Logging/StringExtensions.cs new file mode 100644 index 000000000..a0a5b20f8 --- /dev/null +++ b/src/Wolverine/Logging/StringExtensions.cs @@ -0,0 +1,8 @@ +using JasperFx.Core; + +namespace Wolverine.Logging; + +internal static class StringExtensions +{ + internal static string ToTelemetryFriendly(this string s) => s.SplitPascalCase().Replace(' ', '.').ToLowerInvariant(); +} diff --git a/src/Wolverine/Logging/TenantIdLogContextFrame.cs b/src/Wolverine/Logging/TenantIdLogContextFrame.cs new file mode 100644 index 000000000..7ad92fb9e --- /dev/null +++ b/src/Wolverine/Logging/TenantIdLogContextFrame.cs @@ -0,0 +1,26 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using Wolverine.Runtime; + +namespace Wolverine.Logging; + +public class TenantIdLogContextFrame : SyncFrame +{ + public const string TenantIdContextName = "TenantId"; + private Variable _messageContext = null!; + private Variable _loggingContext = null!; + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _messageContext = chain.FindVariable(typeof(MessageContext)); + yield return _messageContext; + _loggingContext = chain.FindVariable(typeof(LoggingContext)); + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.Write($"{_loggingContext.Usage}.{nameof(LoggingContext.Add)}(\"{TenantIdContextName}\", {_messageContext.Usage}.{nameof(MessageContext.TenantId)} ?? \"[NotSet]\");"); + Next?.GenerateCode(method, writer); + } +}