diff --git a/Directory.Packages.props b/Directory.Packages.props
index 361fc59c..4e24d7e0 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -7,11 +7,11 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsWolverineExtension.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsWolverineExtension.cs
new file mode 100644
index 00000000..01a4274f
--- /dev/null
+++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsWolverineExtension.cs
@@ -0,0 +1,16 @@
+using Wolverine;
+using Wolverine.Attributes;
+
+[assembly: WolverineModule(typeof(SimpleModule.AuditLogs.AuditLogsWolverineExtension))]
+
+namespace SimpleModule.AuditLogs;
+
+#pragma warning disable CA1812 // Instantiated by Wolverine via [WolverineModule]
+internal sealed class AuditLogsWolverineExtension : IWolverineExtension
+#pragma warning restore CA1812
+{
+ public void Configure(WolverineOptions options)
+ {
+ options.Discovery.IncludeAssembly(typeof(AuditLogsWolverineExtension).Assembly);
+ }
+}
diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Middleware/AuditMiddleware.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Middleware/AuditMiddleware.cs
index 1e3d38a5..2c10d630 100644
--- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Middleware/AuditMiddleware.cs
+++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Middleware/AuditMiddleware.cs
@@ -6,19 +6,43 @@
using SimpleModule.AuditLogs.Enrichment;
using SimpleModule.AuditLogs.Pipeline;
using SimpleModule.Settings.Contracts;
+using ZiggyCreatures.Caching.Fusion;
namespace SimpleModule.AuditLogs.Middleware;
-public sealed class AuditMiddleware(RequestDelegate next)
+public sealed class AuditMiddleware(RequestDelegate next, IFusionCache cache)
{
private static readonly string[] ExcludedMethodsForBody = ["HEAD", "OPTIONS"];
+ private static readonly string[] DefaultExcludedPaths =
+ [
+ "/health",
+ "/metrics",
+ "/_content",
+ "/js/",
+ "/css/",
+ "/favicon",
+ ];
+
+ private static readonly FusionCacheEntryOptions AuditConfigCacheOptions = new()
+ {
+ Duration = TimeSpan.FromSeconds(60),
+ };
+
+ private static readonly AuditRequestSettings FallbackSettings = new(
+ CaptureHttp: true,
+ CaptureRequestBodies: true,
+ CaptureQueryStrings: true,
+ CaptureUserAgent: false,
+ ExcludedPaths: DefaultExcludedPaths
+ );
+
private sealed record AuditRequestSettings(
bool CaptureHttp,
bool CaptureRequestBodies,
bool CaptureQueryStrings,
bool CaptureUserAgent,
- IReadOnlyList ExcludedPaths
+ string[] ExcludedPaths
);
public async Task InvokeAsync(HttpContext context)
@@ -30,9 +54,9 @@ public async Task InvokeAsync(HttpContext context)
return;
}
- // Load all settings once at the start (parallel batch loading)
- var settings = context.RequestServices.GetService();
- var auditSettings = await LoadSettingsAsync(settings);
+ // Single composite cache entry — avoids 5 separate FusionCache lookups per
+ // request and resolves the scoped ISettingsContracts only on cache miss.
+ var auditSettings = await LoadSettingsAsync(context.RequestServices, cache);
// Check if HTTP capture is enabled
if (!auditSettings.CaptureHttp)
@@ -115,20 +139,24 @@ public async Task InvokeAsync(HttpContext context)
channel.Enqueue(entry);
}
- private static async Task LoadSettingsAsync(ISettingsContracts? settings)
+ private static async Task LoadSettingsAsync(
+ IServiceProvider services,
+ IFusionCache cache
+ )
{
- if (settings is null)
- {
- return new AuditRequestSettings(
- CaptureHttp: true,
- CaptureRequestBodies: true,
- CaptureQueryStrings: true,
- CaptureUserAgent: false,
- ExcludedPaths: GetDefaultExcludedPaths()
- );
- }
+ return await cache.GetOrSetAsync(
+ AuditCacheKeys.RequestConfig,
+ async (_, _) =>
+ {
+ var settings = services.GetService();
+ return settings is null ? FallbackSettings : await BuildSettingsAsync(settings);
+ },
+ AuditConfigCacheOptions
+ );
+ }
- // Fetch all settings in parallel
+ private static async Task BuildSettingsAsync(ISettingsContracts settings)
+ {
var (captureHttp, captureBody, captureQs, captureUa, excludedPathsRaw) =
await LoadAllSettingsAsync(settings);
@@ -175,29 +203,29 @@ ISettingsContracts settings
return (captureHttp, captureBody, captureQs, captureUa, results[4]);
}
- private static List ParseExcludedPaths(string? rawPaths)
+ private static string[] ParseExcludedPaths(string? rawPaths)
{
- var paths = GetDefaultExcludedPaths();
-
if (string.IsNullOrWhiteSpace(rawPaths))
{
- return paths;
+ return DefaultExcludedPaths;
}
- var configured = rawPaths
- .Split(',')
- .Select(p => p.Trim())
- .Where(p => !string.IsNullOrWhiteSpace(p))
- .ToList();
+ var configured = rawPaths.Split(
+ ',',
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
+ );
+ if (configured.Length == 0)
+ {
+ return DefaultExcludedPaths;
+ }
- paths.AddRange(configured);
- return paths;
+ var merged = new string[DefaultExcludedPaths.Length + configured.Length];
+ DefaultExcludedPaths.CopyTo(merged, 0);
+ configured.CopyTo(merged, DefaultExcludedPaths.Length);
+ return merged;
}
- private static List GetDefaultExcludedPaths() =>
- ["/health", "/metrics", "/_content", "/js/", "/css/", "/favicon"];
-
- private static bool IsExcludedPath(string path, IEnumerable configuredPaths)
+ private static bool IsExcludedPath(string path, string[] configuredPaths)
{
foreach (var prefix in configuredPaths)
{
diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditCacheKeys.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditCacheKeys.cs
new file mode 100644
index 00000000..989cc3f1
--- /dev/null
+++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditCacheKeys.cs
@@ -0,0 +1,6 @@
+namespace SimpleModule.AuditLogs.Pipeline;
+
+internal static class AuditCacheKeys
+{
+ public const string RequestConfig = "auditlogs:request-config";
+}
diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidator.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidator.cs
new file mode 100644
index 00000000..0524b3da
--- /dev/null
+++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidator.cs
@@ -0,0 +1,23 @@
+using SimpleModule.Core.Settings;
+using SimpleModule.Settings.Contracts.Events;
+using ZiggyCreatures.Caching.Fusion;
+
+namespace SimpleModule.AuditLogs.Pipeline;
+
+internal static class AuditConfigCacheInvalidator
+{
+ public static ValueTask Handle(SettingChangedEvent @event, IFusionCache cache)
+ {
+ if (@event.Scope != SettingScope.System)
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ if (!@event.Key.StartsWith("auditlogs.", StringComparison.Ordinal))
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ return cache.RemoveAsync(AuditCacheKeys.RequestConfig);
+ }
+}
diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs
index 2673648a..301f1fba 100644
--- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs
+++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs
@@ -23,25 +23,54 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
- if (await channel.Reader.WaitToReadAsync(stoppingToken))
+ if (!await channel.Reader.WaitToReadAsync(stoppingToken))
{
- batch.Clear();
- var deadline = DateTimeOffset.UtcNow.Add(opts.WriterFlushInterval);
-
- while (
- batch.Count < opts.WriterBatchSize
- && DateTimeOffset.UtcNow < deadline
- && channel.Reader.TryRead(out var entry)
- )
+ break;
+ }
+
+ batch.Clear();
+
+ while (batch.Count < opts.WriterBatchSize && channel.Reader.TryRead(out var entry))
+ {
+ batch.Add(entry);
+ }
+
+ if (batch.Count < opts.WriterBatchSize)
+ {
+ using var linger = CancellationTokenSource.CreateLinkedTokenSource(
+ stoppingToken
+ );
+ linger.CancelAfter(opts.WriterFlushInterval);
+
+ try
{
- batch.Add(entry);
- }
+ while (batch.Count < opts.WriterBatchSize)
+ {
+ if (!await channel.Reader.WaitToReadAsync(linger.Token))
+ {
+ break;
+ }
- if (batch.Count > 0)
+ while (
+ batch.Count < opts.WriterBatchSize
+ && channel.Reader.TryRead(out var entry)
+ )
+ {
+ batch.Add(entry);
+ }
+ }
+ }
+ catch (OperationCanceledException)
{
- await FlushBatchAsync(batch, stoppingToken);
+ // Linger deadline OR shutdown — fall through and persist
+ // whatever was already drained instead of dropping it.
}
}
+
+ if (batch.Count > 0)
+ {
+ await FlushBatchAsync(batch);
+ }
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
@@ -64,13 +93,13 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}
if (batch.Count > 0)
{
- await FlushBatchAsync(batch, CancellationToken.None);
+ await FlushBatchAsync(batch);
}
LogStopped(logger);
}
- private async Task FlushBatchAsync(List batch, CancellationToken ct)
+ private async Task FlushBatchAsync(List batch)
{
await using var scope = scopeFactory.CreateAsyncScope();
var contracts = scope.ServiceProvider.GetRequiredService();
diff --git a/modules/AuditLogs/tests/SimpleModule.AuditLogs.Tests/Unit/AuditMiddlewareTests.cs b/modules/AuditLogs/tests/SimpleModule.AuditLogs.Tests/Unit/AuditMiddlewareTests.cs
index 60208f3f..211a7e29 100644
--- a/modules/AuditLogs/tests/SimpleModule.AuditLogs.Tests/Unit/AuditMiddlewareTests.cs
+++ b/modules/AuditLogs/tests/SimpleModule.AuditLogs.Tests/Unit/AuditMiddlewareTests.cs
@@ -1,15 +1,22 @@
using FluentAssertions;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Options;
using NSubstitute;
using SimpleModule.AuditLogs.Middleware;
using SimpleModule.AuditLogs.Pipeline;
using SimpleModule.Core.Settings;
using SimpleModule.Settings.Contracts;
+using ZiggyCreatures.Caching.Fusion;
+using ZiggyCreatures.Caching.Fusion.NullObjects;
namespace AuditLogs.Tests.Unit;
-public class AuditMiddlewareTests
+public sealed class AuditMiddlewareTests : IDisposable
{
+ private readonly NullFusionCache _cache = new(Options.Create(new FusionCacheOptions()));
+
+ public void Dispose() => _cache.Dispose();
+
[Fact]
public async Task InvokeAsync_LoadsSettingsOnce_AndCachesForRequest()
{
@@ -24,7 +31,7 @@ public async Task InvokeAsync_LoadsSettingsOnce_AndCachesForRequest()
// Configure settings to return default values
settings.GetSettingAsync(Arg.Any(), Arg.Any()).Returns("true");
- var middleware = new AuditMiddleware(next);
+ var middleware = new AuditMiddleware(next, _cache);
// Act
await middleware.InvokeAsync(context);
@@ -69,7 +76,7 @@ public async Task InvokeAsync_SkipsProcessing_WhenHttpCaptureDisabled()
.Returns("false");
settings.GetSettingAsync(Arg.Any(), Arg.Any()).Returns("true");
- var middleware = new AuditMiddleware(next);
+ var middleware = new AuditMiddleware(next, _cache);
// Act
await middleware.InvokeAsync(context);
@@ -95,7 +102,7 @@ public async Task InvokeAsync_SkipsProcessing_ForExcludedPath()
settings.GetSettingAsync(Arg.Any(), Arg.Any()).Returns("true");
- var middleware = new AuditMiddleware(next);
+ var middleware = new AuditMiddleware(next, _cache);
// Act
await middleware.InvokeAsync(context);
@@ -134,7 +141,7 @@ public async Task InvokeAsync_ExcludesPath_WhenConfigured()
.GetSettingAsync("auditlogs.excluded.paths", Arg.Any())
.Returns("/api/custom-exclude");
- var middleware = new AuditMiddleware(next);
+ var middleware = new AuditMiddleware(next, _cache);
// Act
await middleware.InvokeAsync(context);
@@ -171,7 +178,7 @@ public async Task InvokeAsync_MergesHardcodedAndConfiguredExcludedPaths()
.GetSettingAsync("auditlogs.excluded.paths", Arg.Any())
.Returns("/api/internal");
- var middleware = new AuditMiddleware(next);
+ var middleware = new AuditMiddleware(next, _cache);
// Act - test hardcoded /health path
await middleware.InvokeAsync(context);
@@ -195,7 +202,7 @@ public async Task InvokeAsync_PathMatching_IsCaseInsensitive()
settings.GetSettingAsync(Arg.Any(), Arg.Any()).Returns("true");
- var middleware = new AuditMiddleware(next);
+ var middleware = new AuditMiddleware(next, _cache);
// Act
await middleware.InvokeAsync(context);
@@ -216,7 +223,7 @@ public async Task InvokeAsync_UsesDefaultSettings_WhenSettingsServiceIsNull()
context.Request.Method = "GET";
context.RequestServices = CreateServiceProviderWithoutSettings(channel);
- var middleware = new AuditMiddleware(next);
+ var middleware = new AuditMiddleware(next, _cache);
// Act
await middleware.InvokeAsync(context);
@@ -240,7 +247,7 @@ public async Task InvokeAsync_DefaultValues_AreCorrect()
settings.GetSettingAsync(Arg.Any(), Arg.Any()).Returns((string?)null); // Simulate no setting found
- var middleware = new AuditMiddleware(next);
+ var middleware = new AuditMiddleware(next, _cache);
// Act
await middleware.InvokeAsync(context);
@@ -261,7 +268,7 @@ public async Task InvokeAsync_HandlesNullSettingsGracefully()
context.Request.Method = "POST";
context.RequestServices = CreateServiceProviderWithoutSettings(channel);
- var middleware = new AuditMiddleware(next);
+ var middleware = new AuditMiddleware(next, _cache);
// Act & Assert - should not throw
var action = () => middleware.InvokeAsync(context);
diff --git a/modules/Localization/src/SimpleModule.Localization/Middleware/LocaleResolutionMiddleware.cs b/modules/Localization/src/SimpleModule.Localization/Middleware/LocaleResolutionMiddleware.cs
index 60dc81d1..9afb8d52 100644
--- a/modules/Localization/src/SimpleModule.Localization/Middleware/LocaleResolutionMiddleware.cs
+++ b/modules/Localization/src/SimpleModule.Localization/Middleware/LocaleResolutionMiddleware.cs
@@ -19,6 +19,9 @@ public sealed class LocaleResolutionMiddleware(
IFusionCache cache
)
{
+ // Bound cache-key size: a hostile client could otherwise pin multi-KB strings.
+ private const int MaxAcceptLanguageKeyLength = 256;
+
private static readonly FusionCacheEntryOptions UserLocaleCacheOptions = new()
{
Duration = TimeSpan.FromMinutes(5),
@@ -88,6 +91,11 @@ private async Task ResolveLocaleAsync(HttpContext context)
return userLocale;
}
}
+
+ // Cache the absence so subsequent requests skip the per-request settings
+ // probe. The cachedHit check above treats empty as "no value" and falls
+ // through, so this short-circuits only the inner DB/cache lookup.
+ await cache.SetAsync(cacheKey, string.Empty, UserLocaleCacheOptions);
}
// No explicit user setting — resolve from Accept-Language header or default
@@ -142,6 +150,9 @@ private string GetDefaultLocale()
private static string UserLocaleKey(string userId) => string.Concat("locale:user:", userId);
- private static string AcceptLanguageKey(string headerValue) =>
- string.Concat("locale:accept:", headerValue);
+ private static string AcceptLanguageKey(string headerValue)
+ {
+ var len = Math.Min(headerValue.Length, MaxAcceptLanguageKeyLength);
+ return string.Concat("locale:accept:", headerValue.AsSpan(0, len));
+ }
}
diff --git a/template/SimpleModule.Host/appsettings.Development.json b/template/SimpleModule.Host/appsettings.Development.json
index 9df8ab6b..2680e643 100644
--- a/template/SimpleModule.Host/appsettings.Development.json
+++ b/template/SimpleModule.Host/appsettings.Development.json
@@ -10,7 +10,8 @@
},
"Logging": {
"LogLevel": {
- "Microsoft.EntityFrameworkCore.Database.Command": "Information"
+ "Microsoft.EntityFrameworkCore.Database.Command": "Warning",
+ "ZiggyCreatures.Caching.Fusion": "Warning"
},
"Console": {
"FormatterName": "simple",
diff --git a/template/SimpleModule.Host/appsettings.json b/template/SimpleModule.Host/appsettings.json
index c1ac6311..4a189183 100644
--- a/template/SimpleModule.Host/appsettings.json
+++ b/template/SimpleModule.Host/appsettings.json
@@ -48,7 +48,9 @@
"Logging": {
"LogLevel": {
"Default": "Information",
- "Microsoft.AspNetCore": "Warning"
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.EntityFrameworkCore.Database.Command": "Warning",
+ "ZiggyCreatures.Caching.Fusion": "Warning"
}
}
}