From e7aaf5e179c0c379b1e36b0013fef735882aba82 Mon Sep 17 00:00:00 2001 From: Tadeas Zribko Date: Mon, 30 Mar 2026 15:32:24 +0200 Subject: [PATCH 01/14] feat: implement request context middleware and logging enhancements --- Directory.Packages.props | 1 + .../RequestContextPipelineExtensions.cs | 49 ++++++ .../Extensions/SerilogExtensions.cs | 2 + .../WebApplicationPipelineExtensions.cs | 1 + .../Middleware/RequestContextMiddleware.cs | 96 +++++++++++ .../Http/RequestContextConstants.cs | 46 ++++++ .../Logging/ActivityTraceEnricher.cs | 34 ++++ .../Observability/ObservabilityConventions.cs | 27 ++++ .../TelemetryApiSurfaceResolver.cs | 28 ++++ .../SharedKernel.Infrastructure.csproj | 5 + .../Logging/ActivityTraceEnricherTests.cs | 64 ++++++++ .../RequestContextMiddlewareTests.cs | 150 ++++++++++++++++++ .../TelemetryApiSurfaceResolverTests.cs | 24 +++ 13 files changed, 527 insertions(+) create mode 100644 src/SharedKernel/SharedKernel.Api/Extensions/RequestContextPipelineExtensions.cs create mode 100644 src/SharedKernel/SharedKernel.Api/Middleware/RequestContextMiddleware.cs create mode 100644 src/SharedKernel/SharedKernel.Application/Http/RequestContextConstants.cs create mode 100644 src/SharedKernel/SharedKernel.Infrastructure/Logging/ActivityTraceEnricher.cs create mode 100644 src/SharedKernel/SharedKernel.Infrastructure/Observability/ObservabilityConventions.cs create mode 100644 src/SharedKernel/SharedKernel.Infrastructure/Observability/TelemetryApiSurfaceResolver.cs create mode 100644 tests/SharedKernel.Tests/Logging/ActivityTraceEnricherTests.cs create mode 100644 tests/SharedKernel.Tests/Middleware/RequestContextMiddlewareTests.cs create mode 100644 tests/SharedKernel.Tests/Observability/TelemetryApiSurfaceResolverTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index f6d36144..683e5181 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -68,6 +68,7 @@ + diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/RequestContextPipelineExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/RequestContextPipelineExtensions.cs new file mode 100644 index 00000000..4ec45c7a --- /dev/null +++ b/src/SharedKernel/SharedKernel.Api/Extensions/RequestContextPipelineExtensions.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Serilog; +using Serilog.Events; +using SharedKernel.Api.Middleware; + +namespace SharedKernel.Api.Extensions; + +/// +/// Registers correlation enrichment middleware and structured Serilog request logging. +/// +public static class RequestContextPipelineExtensions +{ + public static WebApplication UseRequestContextPipeline(this WebApplication app) + { + app.UseMiddleware(); + app.UseSerilogRequestLogging(options => + { + options.MessageTemplate = + "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms"; + + options.GetLevel = (httpContext, _, exception) => + { + if (IsClientAbortedRequest(httpContext, exception)) + return LogEventLevel.Information; + + if (exception is not null || httpContext.Response.StatusCode >= 500) + return LogEventLevel.Error; + + if (httpContext.Response.StatusCode >= 400) + return LogEventLevel.Warning; + + return LogEventLevel.Information; + }; + + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); + diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme); + }; + }); + + return app; + } + + private static bool IsClientAbortedRequest(HttpContext httpContext, Exception? exception) => + exception is OperationCanceledException + && httpContext.RequestAborted.IsCancellationRequested; +} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/SerilogExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/SerilogExtensions.cs index 12f7f670..d215550f 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/SerilogExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/SerilogExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Hosting; using Serilog; +using SharedKernel.Infrastructure.Logging; namespace SharedKernel.Api.Extensions; @@ -14,6 +15,7 @@ public static IHostBuilder UseSharedSerilog(this IHostBuilder hostBuilder) .ReadFrom.Configuration(context.Configuration) .ReadFrom.Services(services) .Enrich.FromLogContext() + .Enrich.With() .Enrich.WithProperty("Application", context.HostingEnvironment.ApplicationName); } ); diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs index c3a0c7d9..2f9202b4 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs @@ -30,6 +30,7 @@ bool useOutputCaching ) { app.UseAuthorization(); + app.UseRequestContextPipeline(); if (useOutputCaching) app.UseSharedOutputCaching(); diff --git a/src/SharedKernel/SharedKernel.Api/Middleware/RequestContextMiddleware.cs b/src/SharedKernel/SharedKernel.Api/Middleware/RequestContextMiddleware.cs new file mode 100644 index 00000000..e0a68599 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Api/Middleware/RequestContextMiddleware.cs @@ -0,0 +1,96 @@ +using System.Diagnostics; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Serilog.Context; +using SharedKernel.Application.Http; +using SharedKernel.Application.Security; +using SharedKernel.Infrastructure.Observability; + +namespace SharedKernel.Api.Middleware; + +/// +/// Enriches each request with correlation, tracing, timing, tenant metadata, and metrics tags. +/// +public sealed class RequestContextMiddleware +{ + private readonly RequestDelegate _next; + + public RequestContextMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + string correlationId = ResolveCorrelationId(context); + Stopwatch stopwatch = Stopwatch.StartNew(); + string traceId = Activity.Current?.TraceId.ToHexString() ?? context.TraceIdentifier; + + string? tenantId = context.User.FindFirstValue(SharedAuthConstants.Claims.TenantId); + string effectiveTenantId = !string.IsNullOrWhiteSpace(tenantId) ? tenantId : string.Empty; + + if (!string.IsNullOrWhiteSpace(effectiveTenantId)) + Activity.Current?.SetTag(TelemetryTagKeys.TenantId, effectiveTenantId); + + context.Items[RequestContextConstants.ContextKeys.CorrelationId] = correlationId; + context.Response.Headers[RequestContextConstants.Headers.CorrelationId] = correlationId; + context.Response.Headers[RequestContextConstants.Headers.TraceId] = traceId; + context.Response.Headers[RequestContextConstants.Headers.ElapsedMs] = "0"; + + context.Response.OnStarting(() => + { + context.Response.Headers[RequestContextConstants.Headers.ElapsedMs] = + stopwatch.ElapsedMilliseconds.ToString(); + return Task.CompletedTask; + }); + + try + { + using ( + LogContext.PushProperty( + RequestContextConstants.LogProperties.CorrelationId, + correlationId + ) + ) + using ( + LogContext.PushProperty( + RequestContextConstants.LogProperties.TenantId, + effectiveTenantId + ) + ) + { + await _next(context); + } + } + finally + { + IHttpMetricsTagsFeature? metricsTagsFeature = + context.Features.Get(); + if (metricsTagsFeature is not null) + { + metricsTagsFeature.Tags.Add( + new( + TelemetryTagKeys.ApiSurface, + TelemetryApiSurfaceResolver.Resolve(context.Request.Path) + ) + ); + metricsTagsFeature.Tags.Add( + new( + TelemetryTagKeys.Authenticated, + context.User.Identity?.IsAuthenticated == true + ) + ); + } + } + } + + private static string ResolveCorrelationId(HttpContext context) + { + string incoming = context + .Request.Headers[RequestContextConstants.Headers.CorrelationId] + .ToString(); + + return !string.IsNullOrWhiteSpace(incoming) ? incoming : context.TraceIdentifier; + } +} diff --git a/src/SharedKernel/SharedKernel.Application/Http/RequestContextConstants.cs b/src/SharedKernel/SharedKernel.Application/Http/RequestContextConstants.cs new file mode 100644 index 00000000..62b7a6d1 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Application/Http/RequestContextConstants.cs @@ -0,0 +1,46 @@ +namespace SharedKernel.Application.Http; + +/// +/// Constants for request context headers and log enrichment properties. +/// +public static class RequestContextConstants +{ + public static class Headers + { + /// + /// Header name used for correlation IDs supplied by the caller. + /// + public const string CorrelationId = "X-Correlation-Id"; + + /// + /// Header name used for the distributed trace ID. + /// + public const string TraceId = "X-Trace-Id"; + + /// + /// Header name used for the request elapsed time in milliseconds. + /// + public const string ElapsedMs = "X-Elapsed-Ms"; + } + + public static class ContextKeys + { + /// + /// Key under which the resolved correlation ID is stored in . + /// + public const string CorrelationId = "CorrelationId"; + } + + public static class LogProperties + { + /// + /// Serilog property name for the correlation ID. + /// + public const string CorrelationId = "CorrelationId"; + + /// + /// Serilog property name for the tenant ID. + /// + public const string TenantId = "TenantId"; + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Logging/ActivityTraceEnricher.cs b/src/SharedKernel/SharedKernel.Infrastructure/Logging/ActivityTraceEnricher.cs new file mode 100644 index 00000000..66e8275c --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Logging/ActivityTraceEnricher.cs @@ -0,0 +1,34 @@ +using System.Diagnostics; +using Serilog.Core; +using Serilog.Events; + +namespace SharedKernel.Infrastructure.Logging; + +/// +/// Serilog that appends W3C-format TraceId and SpanId +/// properties from the current to every log event, +/// enabling correlation between structured logs and distributed traces. +/// +public sealed class ActivityTraceEnricher : ILogEventEnricher +{ + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + Activity? activity = Activity.Current; + if (activity is null) + return; + + if (activity.TraceId != default) + { + logEvent.AddPropertyIfAbsent( + propertyFactory.CreateProperty("TraceId", activity.TraceId.ToHexString()) + ); + } + + if (activity.SpanId != default) + { + logEvent.AddPropertyIfAbsent( + propertyFactory.CreateProperty("SpanId", activity.SpanId.ToHexString()) + ); + } + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/ObservabilityConventions.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ObservabilityConventions.cs new file mode 100644 index 00000000..f564c594 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ObservabilityConventions.cs @@ -0,0 +1,27 @@ +namespace SharedKernel.Infrastructure.Observability; + +/// Canonical tag/attribute key names applied to metrics and traces. +public static class TelemetryTagKeys +{ + public const string ApiSurface = "apitemplate.api.surface"; + public const string Authenticated = "apitemplate.authenticated"; + public const string TenantId = "tenant.id"; +} + +/// Well-known tag values that identify the API surface a request was served from. +public static class TelemetrySurfaces +{ + public const string Documentation = "documentation"; + public const string GraphQl = "graphql"; + public const string Health = "health"; + public const string Rest = "rest"; +} + +/// URL path prefixes used to classify requests into API surface areas. +public static class TelemetryPathPrefixes +{ + public const string GraphQl = "/graphql"; + public const string Health = "/health"; + public const string OpenApi = "/openapi"; + public const string Scalar = "/scalar"; +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/TelemetryApiSurfaceResolver.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/TelemetryApiSurfaceResolver.cs new file mode 100644 index 00000000..46ef32b5 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/TelemetryApiSurfaceResolver.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Http; + +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Maps an HTTP request path to a logical API surface name for use as a telemetry tag value. +/// +public static class TelemetryApiSurfaceResolver +{ + public static string Resolve(PathString path) + { + if (path.StartsWithSegments(TelemetryPathPrefixes.GraphQl)) + return TelemetrySurfaces.GraphQl; + + if (path.StartsWithSegments(TelemetryPathPrefixes.Health)) + return TelemetrySurfaces.Health; + + if ( + path.StartsWithSegments(TelemetryPathPrefixes.Scalar) + || path.StartsWithSegments(TelemetryPathPrefixes.OpenApi) + ) + { + return TelemetrySurfaces.Documentation; + } + + return TelemetrySurfaces.Rest; + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj b/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj index 6f7f1198..87828d54 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj +++ b/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj @@ -11,12 +11,17 @@ enable + + + + + diff --git a/tests/SharedKernel.Tests/Logging/ActivityTraceEnricherTests.cs b/tests/SharedKernel.Tests/Logging/ActivityTraceEnricherTests.cs new file mode 100644 index 00000000..3f4cdf90 --- /dev/null +++ b/tests/SharedKernel.Tests/Logging/ActivityTraceEnricherTests.cs @@ -0,0 +1,64 @@ +using System.Diagnostics; +using Serilog.Core; +using Serilog.Events; +using SharedKernel.Infrastructure.Logging; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Logging; + +public sealed class ActivityTraceEnricherTests +{ + private readonly ActivityTraceEnricher _sut = new(); + + [Fact] + public void Enrich_WithActiveActivity_AddsTraceIdAndSpanId() + { + using ActivityListener listener = new() + { + ShouldListenTo = static _ => true, + Sample = static (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllData, + }; + ActivitySource.AddActivityListener(listener); + + using ActivitySource source = new("SharedKernel.Tests"); + using Activity? activity = source.StartActivity("test-operation"); + + activity.ShouldNotBeNull(); + + LogEvent logEvent = CreateLogEvent(); + _sut.Enrich(logEvent, new TestLogEventPropertyFactory()); + + logEvent.Properties.ContainsKey("TraceId").ShouldBeTrue(); + logEvent.Properties.ContainsKey("SpanId").ShouldBeTrue(); + ((ScalarValue)logEvent.Properties["TraceId"]).Value.ShouldBe( + activity.TraceId.ToHexString() + ); + ((ScalarValue)logEvent.Properties["SpanId"]).Value.ShouldBe(activity.SpanId.ToHexString()); + } + + [Fact] + public void Enrich_WithNoActivity_AddsNoProperties() + { + Activity.Current = null; + + LogEvent logEvent = CreateLogEvent(); + _sut.Enrich(logEvent, new TestLogEventPropertyFactory()); + + logEvent.Properties.ContainsKey("TraceId").ShouldBeFalse(); + logEvent.Properties.ContainsKey("SpanId").ShouldBeFalse(); + } + + private static LogEvent CreateLogEvent() => + new(DateTimeOffset.UtcNow, LogEventLevel.Information, null, MessageTemplate.Empty, []); + + private sealed class TestLogEventPropertyFactory : ILogEventPropertyFactory + { + public LogEventProperty CreateProperty( + string name, + object? value, + bool destructureObjects = false + ) => new(name, new ScalarValue(value)); + } +} diff --git a/tests/SharedKernel.Tests/Middleware/RequestContextMiddlewareTests.cs b/tests/SharedKernel.Tests/Middleware/RequestContextMiddlewareTests.cs new file mode 100644 index 00000000..ec503201 --- /dev/null +++ b/tests/SharedKernel.Tests/Middleware/RequestContextMiddlewareTests.cs @@ -0,0 +1,150 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using SharedKernel.Api.Middleware; +using SharedKernel.Application.Http; +using SharedKernel.Application.Security; +using SharedKernel.Infrastructure.Observability; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Middleware; + +public sealed class RequestContextMiddlewareTests +{ + [Fact] + public async Task InvokeAsync_WhenHeaderProvided_EchoesCorrelationIdToResponse() + { + DefaultHttpContext context = CreateContext(); + context.Request.Headers[RequestContextConstants.Headers.CorrelationId] = "corr-123"; + + RequestContextMiddleware sut = CreateSut(); + + await sut.InvokeAsync(context); + + context + .Response.Headers[RequestContextConstants.Headers.CorrelationId] + .ToString() + .ShouldBe("corr-123"); + context.Items[RequestContextConstants.ContextKeys.CorrelationId].ShouldBe("corr-123"); + } + + [Fact] + public async Task InvokeAsync_WhenHeaderMissing_UsesTraceIdentifierAsCorrelationId() + { + DefaultHttpContext context = CreateContext(); + context.TraceIdentifier = "trace-xyz"; + + RequestContextMiddleware sut = CreateSut(); + + await sut.InvokeAsync(context); + + context + .Response.Headers[RequestContextConstants.Headers.CorrelationId] + .ToString() + .ShouldBe("trace-xyz"); + context.Items[RequestContextConstants.ContextKeys.CorrelationId].ShouldBe("trace-xyz"); + } + + [Fact] + public async Task InvokeAsync_PopulatesTraceIdAndElapsedHeaders() + { + DefaultHttpContext context = CreateContext(); + + RequestContextMiddleware sut = CreateSut(); + + await sut.InvokeAsync(context); + + context + .Response.Headers[RequestContextConstants.Headers.TraceId] + .ToString() + .ShouldNotBeNullOrWhiteSpace(); + context + .Response.Headers[RequestContextConstants.Headers.ElapsedMs] + .ToString() + .ShouldNotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task InvokeAsync_TagsMetricsFeatureWithRestSurface() + { + DefaultHttpContext context = CreateContext(); + context.Request.Path = "/api/v1/products"; + FakeHttpMetricsTagsFeature metricsFeature = new(); + context.Features.Set(metricsFeature); + + RequestContextMiddleware sut = CreateSut(); + + await sut.InvokeAsync(context); + + metricsFeature.Tags.ShouldContain(tag => + tag.Key == TelemetryTagKeys.ApiSurface && (string?)tag.Value == TelemetrySurfaces.Rest + ); + } + + [Fact] + public async Task InvokeAsync_TagsMetricsFeatureWithAuthenticatedStatus() + { + DefaultHttpContext context = CreateContext(); + context.User = new ClaimsPrincipal( + new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "user-1")], "TestAuth") + ); + FakeHttpMetricsTagsFeature metricsFeature = new(); + context.Features.Set(metricsFeature); + + RequestContextMiddleware sut = CreateSut(); + + await sut.InvokeAsync(context); + + metricsFeature + .Tags.Any(tag => tag.Key == TelemetryTagKeys.Authenticated && Equals(tag.Value, true)) + .ShouldBeTrue(); + } + + [Fact] + public async Task InvokeAsync_WithTenantClaim_StoresCorrelationAndDoesNotFail() + { + DefaultHttpContext context = CreateContext(); + context.TraceIdentifier = "trace-with-tenant"; + string tenantId = Guid.NewGuid().ToString(); + context.User = new ClaimsPrincipal( + new ClaimsIdentity( + [new Claim(SharedAuthConstants.Claims.TenantId, tenantId)], + "TestAuth" + ) + ); + + RequestContextMiddleware sut = CreateSut(); + + await sut.InvokeAsync(context); + + context + .Items[RequestContextConstants.ContextKeys.CorrelationId] + .ShouldBe("trace-with-tenant"); + context + .Response.Headers[RequestContextConstants.Headers.CorrelationId] + .ToString() + .ShouldBe("trace-with-tenant"); + } + + private static RequestContextMiddleware CreateSut() => + new(async context => + { + await context.Response.WriteAsync("ok"); + }); + + private static DefaultHttpContext CreateContext() + { + DefaultHttpContext context = new(); + context.Response.Body = new MemoryStream(); + return context; + } + + private sealed class FakeHttpMetricsTagsFeature : IHttpMetricsTagsFeature + { + public bool MetricsDisabled { get; set; } + + public ICollection> Tags { get; } = + new List>(); + } +} diff --git a/tests/SharedKernel.Tests/Observability/TelemetryApiSurfaceResolverTests.cs b/tests/SharedKernel.Tests/Observability/TelemetryApiSurfaceResolverTests.cs new file mode 100644 index 00000000..5cfde200 --- /dev/null +++ b/tests/SharedKernel.Tests/Observability/TelemetryApiSurfaceResolverTests.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Http; +using SharedKernel.Infrastructure.Observability; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Observability; + +public sealed class TelemetryApiSurfaceResolverTests +{ + [Theory] + [InlineData("/graphql", TelemetrySurfaces.GraphQl)] + [InlineData("/graphql/ui", TelemetrySurfaces.GraphQl)] + [InlineData("/health", TelemetrySurfaces.Health)] + [InlineData("/openapi/v1/openapi.json", TelemetrySurfaces.Documentation)] + [InlineData("/scalar/v1", TelemetrySurfaces.Documentation)] + [InlineData("/api/v1/products", TelemetrySurfaces.Rest)] + [InlineData("/", TelemetrySurfaces.Rest)] + public void Resolve_ReturnsExpectedSurface(string path, string expected) + { + string actual = TelemetryApiSurfaceResolver.Resolve(new PathString(path)); + + actual.ShouldBe(expected); + } +} From a117f2563ce40cf3c814d1c4832f73c1d91ae1ae Mon Sep 17 00:00:00 2001 From: Tadeas Zribko Date: Mon, 30 Mar 2026 23:58:41 +0200 Subject: [PATCH 02/14] Add observability features for authentication, caching, and health checks - Implement AuthTelemetry for tracking authentication failures and missing tenant claims. - Create CacheTelemetry to monitor output cache invalidations and outcomes. - Introduce ConflictTelemetry for recording concurrency and domain conflicts. - Add HealthCheckMetricsPublisher to publish health check results as metrics. - Develop HttpRouteResolver to resolve normalized route templates for HTTP requests. - Enhance ObservabilityConventions with shared names and tags for metrics. - Implement StartupTelemetry to track startup phase activities and failures. - Create ValidationTelemetry for recording validation failures in MVC actions. - Add RabbitMqHealthCheck to verify RabbitMQ broker connectivity. - Update project files to include internals visibility for testing. - Add unit tests for new observability features and health checks. - Remove obsolete ErrorOrValidationMiddlewareTests. --- .vscode/settings.json | 12 +- APITemplate.Microservices.code-workspace | 42 +++ APITemplate.slnx | 2 +- global.json | 6 +- src/Gateway/Gateway.Api/Program.cs | 2 +- .../BackgroundJobs.Api/Program.cs | 25 +- .../FileStorage/FileStorage.Api/Program.cs | 6 +- src/Services/Identity/Identity.Api/Program.cs | 11 +- .../Notifications.Api/Program.cs | 5 +- .../Health/MongoDbHealthCheck.cs | 26 ++ .../ProductCatalog.Api/Program.cs | 17 +- .../Commands/CreateCategoriesCommand.cs | 8 +- .../Commands/UpdateCategoriesCommand.cs | 48 +++- .../Product/Commands/CreateProductsCommand.cs | 8 +- .../Product/Commands/UpdateProductsCommand.cs | 46 +++- .../Commands/UpdateProductsValidator.cs | 77 ------ .../Persistence/MongoDbContext.cs | 7 +- src/Services/Reviews/Reviews.Api/Program.cs | 11 +- src/Services/Webhooks/Webhooks.Api/Program.cs | 5 +- .../ExceptionHandling/ApiExceptionHandler.cs | 4 + ...HealthChecksServiceCollectionExtensions.cs | 40 +++ .../Extensions/HostExtensions.cs | 12 +- .../Extensions/KeycloakAuthExtensions.cs | 19 +- .../Extensions/ObservabilityExtensions.cs | 252 ++++++++++++++++-- .../Extensions/SharedServiceRegistration.cs | 22 ++ .../WebApplicationPipelineExtensions.cs | 18 +- .../FluentValidationActionFilter.cs | 2 + .../OutputCacheInvalidationService.cs | 8 + .../TenantAwareOutputCachePolicy.cs | 14 +- .../SharedKernel.Api/SharedKernel.Api.csproj | 6 + .../Batch/Rules/FluentValidationBatchRule.cs | 14 +- .../Batch/Rules/IValidationMetrics.cs | 12 + .../Middleware/ErrorOrValidationMiddleware.cs | 53 ---- .../Options/ObservabilityOptions.cs | 39 +++ .../Observability/AuthTelemetry.cs | 70 +++++ .../Observability/CacheTelemetry.cs | 104 ++++++++ .../Observability/ConflictTelemetry.cs | 37 +++ .../Observability/HealthCheckConventions.cs | 24 ++ .../HealthCheckMetricsPublisher.cs | 46 ++++ .../Observability/HttpRouteResolver.cs | 44 +++ .../Observability/ObservabilityConventions.cs | 172 ++++++++++++ .../Observability/StartupTelemetry.cs | 51 ++++ .../Observability/ValidationTelemetry.cs | 80 ++++++ .../SharedKernel.Infrastructure.csproj | 1 + .../RabbitMqConventionExtensions.cs | 6 +- .../HealthChecks/RabbitMqHealthCheck.cs | 42 +++ .../SharedKernel.Messaging.csproj | 8 + .../Health/MongoDbHealthCheckTests.cs | 36 +++ .../ErrorOrValidationMiddlewareTests.cs | 95 ------- .../Observability/CacheTelemetryTests.cs | 18 ++ .../HealthCheckMetricsPublisherTests.cs | 29 ++ ...hChecksServiceCollectionExtensionsTests.cs | 74 +++++ .../Observability/HttpRouteResolverTests.cs | 43 +++ .../ObservabilityExtensionsTests.cs | 89 +++++++ .../Observability/RabbitMqHealthCheckTests.cs | 65 +++++ .../Observability/StartupTelemetryTests.cs | 35 +++ 56 files changed, 1717 insertions(+), 331 deletions(-) create mode 100644 APITemplate.Microservices.code-workspace create mode 100644 src/Services/ProductCatalog/ProductCatalog.Api/Health/MongoDbHealthCheck.cs delete mode 100644 src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsValidator.cs create mode 100644 src/SharedKernel/SharedKernel.Api/Extensions/HealthChecksServiceCollectionExtensions.cs create mode 100644 src/SharedKernel/SharedKernel.Application/Batch/Rules/IValidationMetrics.cs delete mode 100644 src/SharedKernel/SharedKernel.Application/Middleware/ErrorOrValidationMiddleware.cs create mode 100644 src/SharedKernel/SharedKernel.Application/Options/ObservabilityOptions.cs create mode 100644 src/SharedKernel/SharedKernel.Infrastructure/Observability/AuthTelemetry.cs create mode 100644 src/SharedKernel/SharedKernel.Infrastructure/Observability/CacheTelemetry.cs create mode 100644 src/SharedKernel/SharedKernel.Infrastructure/Observability/ConflictTelemetry.cs create mode 100644 src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckConventions.cs create mode 100644 src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs create mode 100644 src/SharedKernel/SharedKernel.Infrastructure/Observability/HttpRouteResolver.cs create mode 100644 src/SharedKernel/SharedKernel.Infrastructure/Observability/StartupTelemetry.cs create mode 100644 src/SharedKernel/SharedKernel.Infrastructure/Observability/ValidationTelemetry.cs create mode 100644 src/SharedKernel/SharedKernel.Messaging/HealthChecks/RabbitMqHealthCheck.cs create mode 100644 tests/ProductCatalog.Tests/Health/MongoDbHealthCheckTests.cs delete mode 100644 tests/SharedKernel.Tests/Middleware/ErrorOrValidationMiddlewareTests.cs create mode 100644 tests/SharedKernel.Tests/Observability/CacheTelemetryTests.cs create mode 100644 tests/SharedKernel.Tests/Observability/HealthCheckMetricsPublisherTests.cs create mode 100644 tests/SharedKernel.Tests/Observability/HealthChecksServiceCollectionExtensionsTests.cs create mode 100644 tests/SharedKernel.Tests/Observability/HttpRouteResolverTests.cs create mode 100644 tests/SharedKernel.Tests/Observability/ObservabilityExtensionsTests.cs create mode 100644 tests/SharedKernel.Tests/Observability/RabbitMqHealthCheckTests.cs create mode 100644 tests/SharedKernel.Tests/Observability/StartupTelemetryTests.cs diff --git a/.vscode/settings.json b/.vscode/settings.json index 9e26dfee..000c9efb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1 +1,11 @@ -{} \ No newline at end of file +{ + "dotnet.defaultSolution": "APITemplate.slnx", + "dotnet.solution.autoOpen": "APITemplate.slnx", + "dotnet.enableWorkspaceBasedDevelopment": false, + "files.exclude": { + "**/monolith": true + }, + "search.exclude": { + "**/monolith": true + } +} diff --git a/APITemplate.Microservices.code-workspace b/APITemplate.Microservices.code-workspace new file mode 100644 index 00000000..08b84492 --- /dev/null +++ b/APITemplate.Microservices.code-workspace @@ -0,0 +1,42 @@ +{ + "folders": [ + { + "name": "src", + "path": "src" + }, + { + "name": "tests", + "path": "tests" + }, + { + "name": "docs", + "path": "docs" + }, + { + "name": "infrastructure", + "path": "infrastructure" + } + ], + "settings": { + "dotnet.defaultSolution": "APITemplate.slnx", + "dotnet.solution.autoOpen": "APITemplate.slnx", + "dotnet.enableWorkspaceBasedDevelopment": false, + "git.openRepositoryInParentFolders": "always", + "files.exclude": { + "**/monolith": true, + "**/bin": true, + "**/obj": true + }, + "search.exclude": { + "**/monolith": true, + "**/bin": true, + "**/obj": true + } + }, + "extensions": { + "recommendations": [ + "ms-dotnettools.csdevkit", + "ms-dotnettools.csharp" + ] + } +} diff --git a/APITemplate.slnx b/APITemplate.slnx index 25e85f46..29857aa7 100644 --- a/APITemplate.slnx +++ b/APITemplate.slnx @@ -28,7 +28,7 @@ diff --git a/global.json b/global.json index a11f48e1..82dabbbf 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "10.0.0", - "rollForward": "latestMajor", + "version": "10.0.100", + "rollForward": "latestFeature", "allowPrerelease": true } -} \ No newline at end of file +} diff --git a/src/Gateway/Gateway.Api/Program.cs b/src/Gateway/Gateway.Api/Program.cs index 35e36be1..08ce7c99 100644 --- a/src/Gateway/Gateway.Api/Program.cs +++ b/src/Gateway/Gateway.Api/Program.cs @@ -14,7 +14,7 @@ WebApplication app = builder.Build(); app.MapReverseProxy(); -app.MapHealthChecks("/health"); +app.MapSharedHealthChecks(); app.MapGatewayScalarUi(); app.Run(); diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Api/Program.cs b/src/Services/BackgroundJobs/BackgroundJobs.Api/Program.cs index 56c69b09..2ec0333e 100644 --- a/src/Services/BackgroundJobs/BackgroundJobs.Api/Program.cs +++ b/src/Services/BackgroundJobs/BackgroundJobs.Api/Program.cs @@ -70,17 +70,19 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); +string? tickerQDragonflyConnectionString = null; + // TickerQ (when enabled) if (backgroundJobsOptions.TickerQ.Enabled) { - string? dragonflyConnectionString = builder.Configuration.GetConnectionString( + tickerQDragonflyConnectionString = builder.Configuration.GetConnectionString( backgroundJobsOptions.TickerQ.CoordinationConnection ); - if (!string.IsNullOrWhiteSpace(dragonflyConnectionString)) + if (!string.IsNullOrWhiteSpace(tickerQDragonflyConnectionString)) { builder.Services.AddSingleton( - ConnectionMultiplexer.Connect(dragonflyConnectionString) + ConnectionMultiplexer.Connect(tickerQDragonflyConnectionString) ); } @@ -126,10 +128,23 @@ } // Health checks -builder.Services.AddHealthChecks(); +IHealthChecksBuilder backgroundJobsHealthChecks = builder + .Services.AddHealthChecks() + .AddPostgreSqlHealthCheck(connectionString) + .AddSharedRabbitMqHealthCheck(builder.Configuration); + +if (backgroundJobsOptions.TickerQ.Enabled) +{ + backgroundJobsHealthChecks.AddPostgreSqlHealthCheck( + connectionString, + SharedKernel.Infrastructure.Observability.HealthCheckNames.Scheduler, + SharedKernel.Infrastructure.Observability.HealthCheckTags.Scheduler + ); + backgroundJobsHealthChecks.AddDragonflyHealthCheck(tickerQDragonflyConnectionString); +} // Controllers -builder.Services.AddControllers(); +builder.Services.AddSharedControllers(); builder.Services.AddSharedOpenApiDocumentation(); // Wolverine with RabbitMQ diff --git a/src/Services/FileStorage/FileStorage.Api/Program.cs b/src/Services/FileStorage/FileStorage.Api/Program.cs index 4e42df5f..9025c45b 100644 --- a/src/Services/FileStorage/FileStorage.Api/Program.cs +++ b/src/Services/FileStorage/FileStorage.Api/Program.cs @@ -47,7 +47,11 @@ builder.Services.AddSharedOutputCaching(builder.Configuration); builder.Services.AddWolverineHttp(); -builder.Services.AddHealthChecks(); +builder + .Services.AddHealthChecks() + .AddPostgreSqlHealthCheck(builder.Configuration.GetRequiredConnectionString("FileStorageDb")) + .AddDragonflyHealthCheck(builder.Configuration.GetConnectionString("Dragonfly")) + .AddSharedRabbitMqHealthCheck(builder.Configuration); builder.Host.UseWolverine(opts => { diff --git a/src/Services/Identity/Identity.Api/Program.cs b/src/Services/Identity/Identity.Api/Program.cs index 9fd4f554..554a7e79 100644 --- a/src/Services/Identity/Identity.Api/Program.cs +++ b/src/Services/Identity/Identity.Api/Program.cs @@ -23,7 +23,6 @@ using SharedKernel.Messaging.Topology; using Wolverine; using Wolverine.EntityFrameworkCore; -using Wolverine.FluentValidation; using Wolverine.Postgresql; using Wolverine.RabbitMQ; @@ -103,19 +102,21 @@ builder.Services.AddValidatorsFromAssemblyContaining(); -builder.Services.AddControllers(); +builder.Services.AddSharedControllers(); builder.Services.AddSharedOpenApiDocumentation(); builder.Services.AddSharedOutputCaching(builder.Configuration); -builder.Services.AddHealthChecks(); +builder + .Services.AddHealthChecks() + .AddPostgreSqlHealthCheck(builder.Configuration.GetRequiredConnectionString("IdentityDb")) + .AddDragonflyHealthCheck(builder.Configuration.GetConnectionString("Dragonfly")) + .AddSharedRabbitMqHealthCheck(builder.Configuration); builder.Host.UseWolverine(opts => { opts.ApplySharedConventions(); opts.ApplySharedRetryPolicies(); - opts.UseFluentValidation(); - opts.Discovery.IncludeAssembly(typeof(IKeycloakAdminService).Assembly); opts.Discovery.IncludeAssembly(typeof(CacheInvalidationHandler).Assembly); diff --git a/src/Services/Notifications/Notifications.Api/Program.cs b/src/Services/Notifications/Notifications.Api/Program.cs index 16ad5c73..1fb32d62 100644 --- a/src/Services/Notifications/Notifications.Api/Program.cs +++ b/src/Services/Notifications/Notifications.Api/Program.cs @@ -53,7 +53,10 @@ builder.Services.AddScoped(); // Health checks -builder.Services.AddHealthChecks(); +builder + .Services.AddHealthChecks() + .AddPostgreSqlHealthCheck(connectionString) + .AddSharedRabbitMqHealthCheck(builder.Configuration); builder.Services.AddSharedOpenApiDocumentation(); builder.Services.AddWolverineHttp(); diff --git a/src/Services/ProductCatalog/ProductCatalog.Api/Health/MongoDbHealthCheck.cs b/src/Services/ProductCatalog/ProductCatalog.Api/Health/MongoDbHealthCheck.cs new file mode 100644 index 00000000..47e7edd4 --- /dev/null +++ b/src/Services/ProductCatalog/ProductCatalog.Api/Health/MongoDbHealthCheck.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ProductCatalog.Infrastructure.Persistence; + +namespace ProductCatalog.Api.Health; + +/// +/// Verifies MongoDB availability using the application's configured MongoDbContext. +/// +public sealed class MongoDbHealthCheck(IMongoDbHealthProbe mongoDbHealthProbe) : IHealthCheck +{ + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default + ) + { + try + { + await mongoDbHealthProbe.PingAsync(cancellationToken); + return HealthCheckResult.Healthy(); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("MongoDB server is unavailable.", ex); + } + } +} diff --git a/src/Services/ProductCatalog/ProductCatalog.Api/Program.cs b/src/Services/ProductCatalog/ProductCatalog.Api/Program.cs index fc5aa171..975f34cd 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Api/Program.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Api/Program.cs @@ -2,6 +2,7 @@ using FluentValidation; using Microsoft.EntityFrameworkCore; using Polly; +using ProductCatalog.Api.Health; using ProductCatalog.Application.Features.Product.Repositories; using ProductCatalog.Application.Features.Product.Validation; using ProductCatalog.Application.Sagas; @@ -16,7 +17,6 @@ using SharedKernel.Messaging.Topology; using Wolverine; using Wolverine.EntityFrameworkCore; -using Wolverine.FluentValidation; using Wolverine.Postgresql; using Wolverine.RabbitMQ; @@ -40,6 +40,7 @@ MongoDbSettings.SectionName ); builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSharedInfrastructure(builder.Configuration); @@ -70,19 +71,25 @@ builder.Services.AddValidatorsFromAssemblyContaining(); -builder.Services.AddControllers(); +builder.Services.AddSharedControllers(); builder.Services.AddSharedOpenApiDocumentation(); builder.Services.AddSharedOutputCaching(builder.Configuration); -builder.Services.AddHealthChecks(); +builder + .Services.AddHealthChecks() + .AddPostgreSqlHealthCheck(builder.Configuration.GetRequiredConnectionString("ProductCatalogDb")) + .AddDragonflyHealthCheck(builder.Configuration.GetConnectionString("Dragonfly")) + .AddCheck( + SharedKernel.Infrastructure.Observability.HealthCheckNames.MongoDb, + tags: SharedKernel.Infrastructure.Observability.HealthCheckTags.Database + ) + .AddSharedRabbitMqHealthCheck(builder.Configuration); builder.Host.UseWolverine(opts => { opts.ApplySharedConventions(); opts.ApplySharedRetryPolicies(); - opts.UseFluentValidation(); - opts.Discovery.IncludeAssembly(typeof(ProductDeletionSaga).Assembly); opts.Discovery.IncludeAssembly(typeof(CacheInvalidationHandler).Assembly); diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/CreateCategoriesCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/CreateCategoriesCommand.cs index f9ecef60..0457a447 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/CreateCategoriesCommand.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/CreateCategoriesCommand.cs @@ -1,5 +1,4 @@ using ErrorOr; -using FluentValidation; using ProductCatalog.Application.Features.Category.DTOs; using ProductCatalog.Domain.Interfaces; using SharedKernel.Application.Batch; @@ -22,17 +21,14 @@ public sealed class CreateCategoriesCommandHandler CreateCategoriesCommand command, ICategoryRepository repository, IUnitOfWork unitOfWork, - IValidator itemValidator, + FluentValidationBatchRule batchRule, CancellationToken ct ) { IReadOnlyList items = command.Request.Items; BatchFailureContext context = new(items); - await context.ApplyRulesAsync( - ct, - new FluentValidationBatchRule(itemValidator) - ); + await context.ApplyRulesAsync(ct, batchRule); if (context.HasFailures) return (context.ToFailureResponse(), CacheInvalidationCascades.None); diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/UpdateCategoriesCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/UpdateCategoriesCommand.cs index 118c7bb5..8bbcca9c 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/UpdateCategoriesCommand.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/UpdateCategoriesCommand.cs @@ -1,5 +1,4 @@ using ErrorOr; -using FluentValidation; using ProductCatalog.Application.Common.Errors; using ProductCatalog.Application.Features.Category.DTOs; using ProductCatalog.Application.Features.Category.Specifications; @@ -20,22 +19,26 @@ public sealed record UpdateCategoriesCommand(UpdateCategoriesRequest Request); /// Handles by validating all items, loading categories in bulk, and updating in a single transaction. public sealed class UpdateCategoriesCommandHandler { - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( + /// + /// Wolverine compound-handler load step: validates and loads categories, short-circuiting the + /// handler pipeline with a failure response when any validation rule fails. + /// + public static async Task<( + HandlerContinuation, + EntityLookup?, + OutgoingMessages + )> LoadAsync( UpdateCategoriesCommand command, ICategoryRepository repository, - IUnitOfWork unitOfWork, - IValidator itemValidator, + FluentValidationBatchRule batchRule, CancellationToken ct ) { IReadOnlyList items = command.Request.Items; BatchFailureContext context = new(items); - await context.ApplyRulesAsync( - ct, - new FluentValidationBatchRule(itemValidator) - ); - // Load all target categories and mark missing ones as failed + await context.ApplyRulesAsync(ct, batchRule); + HashSet requestedIds = items .Where((_, i) => !context.IsFailed(i)) .Select(item => item.Id) @@ -53,10 +56,33 @@ await context.ApplyRulesAsync( ) ); + OutgoingMessages messages = new(); + if (context.HasFailures) - return (context.ToFailureResponse(), CacheInvalidationCascades.None); + { + messages.RespondToSender(context.ToFailureResponse()); + return (HandlerContinuation.Stop, null, messages); + } + + return ( + HandlerContinuation.Continue, + new EntityLookup(categoryMap), + messages + ); + } + + /// Applies changes in a single transaction. + public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( + UpdateCategoriesCommand command, + EntityLookup lookup, + ICategoryRepository repository, + IUnitOfWork unitOfWork, + CancellationToken ct + ) + { + IReadOnlyList items = command.Request.Items; + IReadOnlyDictionary categoryMap = lookup.Entities; - // Apply changes in a single transaction await unitOfWork.ExecuteInTransactionAsync( async () => { diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/CreateProductsCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/CreateProductsCommand.cs index bbcec8c2..1cd00d26 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/CreateProductsCommand.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/CreateProductsCommand.cs @@ -1,6 +1,5 @@ using Contracts.IntegrationEvents.ProductCatalog; using ErrorOr; -using FluentValidation; using ProductCatalog.Application.Features.Product.DTOs; using ProductCatalog.Application.Features.Product.Repositories; using ProductCatalog.Domain.Entities; @@ -28,7 +27,7 @@ public sealed class CreateProductsCommandHandler IProductDataRepository productDataRepository, IUnitOfWork unitOfWork, IMessageBus bus, - IValidator itemValidator, + FluentValidationBatchRule batchRule, TimeProvider timeProvider, CancellationToken ct ) @@ -36,10 +35,7 @@ CancellationToken ct IReadOnlyList items = command.Request.Items; BatchFailureContext context = new(items); - await context.ApplyRulesAsync( - ct, - new FluentValidationBatchRule(itemValidator) - ); + await context.ApplyRulesAsync(ct, batchRule); // Reference checks skip only fluent-validation failures so both category and // product-data issues can be reported for the same index (merged into one failure row). diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsCommand.cs index f354e462..c8a4a791 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsCommand.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsCommand.cs @@ -1,10 +1,12 @@ using ErrorOr; -using FluentValidation; +using ProductCatalog.Application.Common.Errors; using ProductCatalog.Application.Features.Product.DTOs; using ProductCatalog.Application.Features.Product.Repositories; +using ProductCatalog.Application.Features.Product.Specifications; using ProductCatalog.Domain.Entities; using ProductCatalog.Domain.Interfaces; using SharedKernel.Application.Batch; +using SharedKernel.Application.Batch.Rules; using SharedKernel.Application.Common.Events; using SharedKernel.Application.DTOs; using SharedKernel.Domain.Interfaces; @@ -32,31 +34,53 @@ public sealed class UpdateProductsCommandHandler IProductRepository repository, ICategoryRepository categoryRepository, IProductDataRepository productDataRepository, - IValidator itemValidator, + FluentValidationBatchRule batchRule, CancellationToken ct ) { - (BatchResponse? failure, Dictionary? productMap) = - await UpdateProductsValidator.ValidateAndLoadAsync( - command, - repository, + IReadOnlyList items = command.Request.Items; + BatchFailureContext context = new(items); + + await context.ApplyRulesAsync(ct, batchRule); + + HashSet requestedIds = items + .Where((_, i) => !context.IsFailed(i)) + .Select(item => item.Id) + .ToHashSet(); + Dictionary productMap = ( + await repository.ListAsync(new ProductsByIdsWithLinksSpecification(requestedIds), ct) + ).ToDictionary(p => p.Id); + + await context.ApplyRulesAsync( + ct, + new MarkMissingByIdBatchRule( + item => item.Id, + productMap.Keys.ToHashSet(), + ErrorCatalog.Products.NotFoundMessage + ) + ); + + context.AddFailures( + await ProductValidationHelper.CheckProductReferencesAsync( + items, categoryRepository, productDataRepository, - itemValidator, + context.FailedIndices, ct - ); + ) + ); OutgoingMessages messages = new(); - if (failure is not null) + if (context.HasFailures) { - messages.RespondToSender(failure); + messages.RespondToSender(context.ToFailureResponse()); return (HandlerContinuation.Stop, null, messages); } return ( HandlerContinuation.Continue, - new EntityLookup(productMap!), + new EntityLookup(productMap), messages ); } diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsValidator.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsValidator.cs deleted file mode 100644 index 9c89b8d1..00000000 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsValidator.cs +++ /dev/null @@ -1,77 +0,0 @@ -using FluentValidation; -using ProductCatalog.Application.Common.Errors; -using ProductCatalog.Application.Features.Product.DTOs; -using ProductCatalog.Application.Features.Product.Repositories; -using ProductCatalog.Application.Features.Product.Specifications; -using ProductCatalog.Domain.Interfaces; -using SharedKernel.Application.Batch; -using SharedKernel.Application.Batch.Rules; -using SharedKernel.Application.DTOs; -using ProductEntity = ProductCatalog.Domain.Entities.Product; - -namespace ProductCatalog.Application.Features.Product.Commands; - -/// -/// Validates all items in an and loads target products. -/// Returns a failure when any rule fails, or null on the -/// happy path together with the loaded product map. -/// -internal static class UpdateProductsValidator -{ - internal static async Task<( - BatchResponse? Failure, - Dictionary? ProductMap - )> ValidateAndLoadAsync( - UpdateProductsCommand command, - IProductRepository repository, - ICategoryRepository categoryRepository, - IProductDataRepository productDataRepository, - IValidator itemValidator, - CancellationToken ct - ) - { - IReadOnlyList items = command.Request.Items; - BatchFailureContext context = new(items); - - // Validate each item (field-level rules — name, price, etc.) - await context.ApplyRulesAsync( - ct, - new FluentValidationBatchRule(itemValidator) - ); - - // Load all target products and mark missing ones as failed - HashSet requestedIds = items - .Where((_, i) => !context.IsFailed(i)) - .Select(item => item.Id) - .ToHashSet(); - Dictionary productMap = ( - await repository.ListAsync(new ProductsByIdsWithLinksSpecification(requestedIds), ct) - ).ToDictionary(p => p.Id); - - await context.ApplyRulesAsync( - ct, - new MarkMissingByIdBatchRule( - item => item.Id, - productMap.Keys.ToHashSet(), - ErrorCatalog.Products.NotFoundMessage - ) - ); - - // Reference checks skip only earlier failures (validation + missing entity) so - // category and product-data issues on the same row are merged into one failure. - context.AddFailures( - await ProductValidationHelper.CheckProductReferencesAsync( - items, - categoryRepository, - productDataRepository, - context.FailedIndices, - ct - ) - ); - - if (context.HasFailures) - return (context.ToFailureResponse(), null); - - return (null, productMap); - } -} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs index cecb2d35..8be2cc42 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs @@ -10,7 +10,12 @@ namespace ProductCatalog.Infrastructure.Persistence; /// Thin wrapper around the MongoDB driver that configures the client with diagnostic /// activity tracing and exposes typed collection accessors for domain document types. /// -public sealed class MongoDbContext +public interface IMongoDbHealthProbe +{ + Task PingAsync(CancellationToken cancellationToken = default); +} + +public sealed class MongoDbContext : IMongoDbHealthProbe { private readonly IMongoDatabase _database; diff --git a/src/Services/Reviews/Reviews.Api/Program.cs b/src/Services/Reviews/Reviews.Api/Program.cs index 25d71398..1a1a89fa 100644 --- a/src/Services/Reviews/Reviews.Api/Program.cs +++ b/src/Services/Reviews/Reviews.Api/Program.cs @@ -13,7 +13,6 @@ using SharedKernel.Messaging.Topology; using Wolverine; using Wolverine.EntityFrameworkCore; -using Wolverine.FluentValidation; using Wolverine.Postgresql; using Wolverine.RabbitMQ; @@ -39,19 +38,21 @@ builder.Services.AddValidatorsFromAssemblyContaining(); -builder.Services.AddControllers(); +builder.Services.AddSharedControllers(); builder.Services.AddSharedOpenApiDocumentation(); builder.Services.AddSharedOutputCaching(builder.Configuration); -builder.Services.AddHealthChecks(); +builder + .Services.AddHealthChecks() + .AddPostgreSqlHealthCheck(builder.Configuration.GetRequiredConnectionString("ReviewsDb")) + .AddDragonflyHealthCheck(builder.Configuration.GetConnectionString("Dragonfly")) + .AddSharedRabbitMqHealthCheck(builder.Configuration); builder.Host.UseWolverine(opts => { opts.ApplySharedConventions(); opts.ApplySharedRetryPolicies(); - opts.UseFluentValidation(); - opts.Discovery.IncludeAssembly(typeof(ProductCreatedEventHandler).Assembly); opts.Discovery.IncludeAssembly(typeof(CacheInvalidationHandler).Assembly); diff --git a/src/Services/Webhooks/Webhooks.Api/Program.cs b/src/Services/Webhooks/Webhooks.Api/Program.cs index b8447e7f..c5bc6fcf 100644 --- a/src/Services/Webhooks/Webhooks.Api/Program.cs +++ b/src/Services/Webhooks/Webhooks.Api/Program.cs @@ -66,7 +66,10 @@ ); // Health checks -builder.Services.AddHealthChecks(); +builder + .Services.AddHealthChecks() + .AddPostgreSqlHealthCheck(connectionString) + .AddSharedRabbitMqHealthCheck(builder.Configuration); builder.Services.AddSharedOpenApiDocumentation(); builder.Services.AddWolverineHttp(); diff --git a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandler.cs b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandler.cs index 4aa4681a..88c7070f 100644 --- a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandler.cs +++ b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandler.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using SharedKernel.Application.Errors; using SharedKernel.Domain.Exceptions; +using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.ExceptionHandling; @@ -56,6 +57,9 @@ CancellationToken cancellationToken IReadOnlyDictionary? metadata ) = Resolve(exception); + if (statusCode >= StatusCodes.Status409Conflict) + ConflictTelemetry.Record(exception, errorCode); + ProblemDetails problemDetails = new ProblemDetails { Status = statusCode, diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/HealthChecksServiceCollectionExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/HealthChecksServiceCollectionExtensions.cs new file mode 100644 index 00000000..2bdbfada --- /dev/null +++ b/src/SharedKernel/SharedKernel.Api/Extensions/HealthChecksServiceCollectionExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SharedKernel.Infrastructure.Observability; +using SharedKernel.Messaging.HealthChecks; + +namespace SharedKernel.Api.Extensions; + +/// +/// Shared infrastructure health-check registration helpers for microservice hosts. +/// +public static class HealthChecksServiceCollectionExtensions +{ + public static IHealthChecksBuilder AddPostgreSqlHealthCheck( + this IHealthChecksBuilder builder, + string connectionString, + string name = HealthCheckNames.PostgreSql, + string[]? tags = null + ) => builder.AddNpgSql(connectionString, name: name, tags: tags ?? HealthCheckTags.Database); + + public static IHealthChecksBuilder AddDragonflyHealthCheck( + this IHealthChecksBuilder builder, + string? connectionString, + string name = HealthCheckNames.Dragonfly, + string[]? tags = null + ) + { + if (string.IsNullOrWhiteSpace(connectionString)) + return builder; + + return builder.AddRedis(connectionString, name: name, tags: tags ?? HealthCheckTags.Cache); + } + + public static IHealthChecksBuilder AddSharedRabbitMqHealthCheck( + this IHealthChecksBuilder builder, + IConfiguration configuration, + string name = HealthCheckNames.RabbitMq, + string[]? tags = null + ) => builder.AddCheck(name, tags: tags ?? HealthCheckTags.Messaging); +} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/HostExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/HostExtensions.cs index 393d0490..209a39ba 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/HostExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/HostExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.Extensions; @@ -11,6 +12,15 @@ public static async Task MigrateDbAsync(this WebApplication app) { using AsyncServiceScope scope = app.Services.CreateAsyncScope(); TDbContext dbContext = scope.ServiceProvider.GetRequiredService(); - await dbContext.Database.MigrateAsync(); + using StartupTelemetry.Scope telemetry = StartupTelemetry.StartRelationalMigration(); + try + { + await dbContext.Database.MigrateAsync(); + } + catch (Exception ex) + { + telemetry.Fail(ex); + throw; + } } } diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/KeycloakAuthExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/KeycloakAuthExtensions.cs index 9e7fc35e..42186839 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/KeycloakAuthExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/KeycloakAuthExtensions.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.Tokens; using SharedKernel.Application.Security; +using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.Extensions; @@ -79,9 +80,25 @@ private static void WrapTokenValidated(JwtBearerOptions options, bool requireTen await existingHandler(context); if (requireTenantClaim && !HasValidTenantClaim(context.Principal)) + { + AuthTelemetry.RecordMissingTenantClaim( + context.HttpContext, + JwtBearerDefaults.AuthenticationScheme + ); context.Fail($"Missing required {SharedAuthConstants.Claims.TenantId} claim."); + } + }, + OnAuthenticationFailed = async context => + { + if (existingEvents.OnAuthenticationFailed is not null) + await existingEvents.OnAuthenticationFailed(context); + + AuthTelemetry.RecordAuthenticationFailed( + context.HttpContext, + JwtBearerDefaults.AuthenticationScheme, + context.Exception + ); }, - OnAuthenticationFailed = existingEvents.OnAuthenticationFailed, OnChallenge = existingEvents.OnChallenge, OnForbidden = existingEvents.OnForbidden, OnMessageReceived = existingEvents.OnMessageReceived, diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs index 423ab013..a634e2ef 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs @@ -1,10 +1,17 @@ +using System.Diagnostics; +using System.Reflection; +using System.Runtime.InteropServices; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Npgsql; +using OpenTelemetry.Instrumentation.AspNetCore; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; +using SharedKernel.Application.Options; +using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.Extensions; @@ -17,38 +24,225 @@ public static IServiceCollection AddSharedObservability( string serviceName ) { - string otlpEndpoint = configuration["Observability:Otlp:Endpoint"] ?? "http://alloy:4317"; + services.AddValidatedOptions( + configuration, + ObservabilityOptions.SectionName + ); + services.AddSingleton(); + services.Configure(options => + { + options.Delay = TimeSpan.FromSeconds(15); + options.Period = TimeSpan.FromMinutes(5); + }); - services + Activity.DefaultIdFormat = ActivityIdFormat.W3C; + Activity.ForceDefaultIdFormat = true; + + ObservabilityOptions options = GetObservabilityOptions(configuration); + Dictionary resourceAttributes = BuildResourceAttributes( + serviceName, + environment + ); + bool enableConsoleExporter = IsConsoleExporterEnabled(options); + IReadOnlyList otlpEndpoints = GetEnabledOtlpEndpoints(options, environment); + + var openTelemetryBuilder = services .AddOpenTelemetry() - .ConfigureResource(resource => - resource.AddService(serviceName: serviceName, serviceVersion: "1.0.0") - ) - .WithTracing(tracing => - { - tracing - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddNpgsql() - .AddSource("Wolverine"); - - if (environment.IsDevelopment()) - tracing.AddConsoleExporter(); - - tracing.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)); - }) - .WithMetrics(metrics => - { - metrics - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation() - .AddProcessInstrumentation() - .AddMeter("Wolverine"); - - metrics.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)); - }); + .ConfigureResource(resource => resource.AddAttributes(resourceAttributes)); + + openTelemetryBuilder.WithTracing(builder => + { + builder + .AddAspNetCoreInstrumentation(ConfigureAspNetCoreTracing) + .AddHttpClientInstrumentation() + .AddRedisInstrumentation() + .AddNpgsql() + .AddSource( + ObservabilityConventions.ActivitySourceName, + TelemetryThirdPartySources.Wolverine + ); + + ConfigureTracingExporters(builder, otlpEndpoints, enableConsoleExporter); + }); + + openTelemetryBuilder.WithMetrics(builder => + { + builder + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddProcessInstrumentation() + .AddMeter( + ObservabilityConventions.MeterName, + ObservabilityConventions.HealthMeterName, + TelemetryMeterNames.AspNetCoreHosting, + TelemetryMeterNames.AspNetCoreServerKestrel, + TelemetryMeterNames.AspNetCoreConnections, + TelemetryMeterNames.AspNetCoreRouting, + TelemetryMeterNames.AspNetCoreDiagnostics, + TelemetryMeterNames.AspNetCoreRateLimiting, + TelemetryMeterNames.AspNetCoreAuthentication, + TelemetryMeterNames.AspNetCoreAuthorization, + TelemetryThirdPartySources.Wolverine + ) + .AddView( + TelemetryInstrumentNames.HttpServerRequestDuration, + new ExplicitBucketHistogramConfiguration + { + Boundaries = TelemetryHistogramBoundaries.HttpRequestDurationSeconds, + } + ) + .AddView( + TelemetryInstrumentNames.HttpClientRequestDuration, + new ExplicitBucketHistogramConfiguration + { + Boundaries = TelemetryHistogramBoundaries.HttpRequestDurationSeconds, + } + ) + .AddView( + TelemetryMetricNames.OutputCacheInvalidationDuration, + new ExplicitBucketHistogramConfiguration + { + Boundaries = TelemetryHistogramBoundaries.CacheOperationDurationMs, + } + ); + + ConfigureMetricExporters(builder, otlpEndpoints, enableConsoleExporter); + }); return services; } + + internal static IReadOnlyList GetEnabledOtlpEndpoints( + ObservabilityOptions options, + IHostEnvironment environment + ) + { + List endpoints = []; + + if (IsAspireExporterEnabled(options, environment)) + { + string aspireEndpoint = string.IsNullOrWhiteSpace(options.Aspire.Endpoint) + ? TelemetryDefaults.AspireOtlpEndpoint + : options.Aspire.Endpoint; + endpoints.Add(aspireEndpoint); + } + + if ( + IsOtlpExporterEnabled(options, environment) + && !string.IsNullOrWhiteSpace(options.Otlp.Endpoint) + ) + { + endpoints.Add(options.Otlp.Endpoint); + } + + return endpoints.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } + + internal static bool IsAspireExporterEnabled( + ObservabilityOptions options, + IHostEnvironment environment + ) => + options.Exporters.Aspire.Enabled + ?? (environment.IsDevelopment() && !IsRunningInContainer()); + + internal static bool IsOtlpExporterEnabled( + ObservabilityOptions options, + IHostEnvironment environment + ) => options.Exporters.Otlp.Enabled ?? IsRunningInContainer(); + + internal static bool IsConsoleExporterEnabled(ObservabilityOptions options) => + options.Exporters.Console.Enabled ?? false; + + internal static ObservabilityOptions GetObservabilityOptions(IConfiguration configuration) => + configuration.GetSection(ObservabilityOptions.SectionName).Get() + ?? new(); + + internal static Dictionary BuildResourceAttributes( + string serviceName, + IHostEnvironment environment + ) + { + AssemblyName? entryAssembly = Assembly.GetEntryAssembly()?.GetName(); + string machineName = Environment.MachineName; + int processId = Environment.ProcessId; + + return new Dictionary + { + [TelemetryResourceAttributeKeys.AssemblyName] = entryAssembly?.Name ?? serviceName, + [TelemetryResourceAttributeKeys.ServiceName] = serviceName, + [TelemetryResourceAttributeKeys.ServiceNamespace] = serviceName, + [TelemetryResourceAttributeKeys.ServiceVersion] = + entryAssembly?.Version?.ToString() ?? TelemetryDefaults.Unknown, + [TelemetryResourceAttributeKeys.ServiceInstanceId] = $"{machineName}-{processId}", + [TelemetryResourceAttributeKeys.DeploymentEnvironmentName] = + environment.EnvironmentName, + [TelemetryResourceAttributeKeys.HostName] = machineName, + [TelemetryResourceAttributeKeys.HostArchitecture] = + RuntimeInformation.OSArchitecture.ToString(), + [TelemetryResourceAttributeKeys.OsType] = GetOsType(), + [TelemetryResourceAttributeKeys.ProcessPid] = processId, + [TelemetryResourceAttributeKeys.ProcessRuntimeName] = ".NET", + [TelemetryResourceAttributeKeys.ProcessRuntimeVersion] = Environment.Version.ToString(), + }; + } + + internal static void ConfigureAspNetCoreTracing(AspNetCoreTraceInstrumentationOptions options) + { + options.RecordException = true; + options.Filter = httpContext => + !httpContext.Request.Path.StartsWithSegments(TelemetryPathPrefixes.Health); + options.EnrichWithHttpRequest = (activity, httpRequest) => + { + if (TelemetryApiSurfaceResolver.Resolve(httpRequest.Path) != TelemetrySurfaces.Rest) + return; + + string route = HttpRouteResolver.Resolve(httpRequest.HttpContext); + activity.DisplayName = $"{httpRequest.Method} {route}"; + activity.SetTag(TelemetryTagKeys.HttpRoute, route); + }; + } + + private static bool IsRunningInContainer() => + string.Equals( + Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), + "true", + StringComparison.OrdinalIgnoreCase + ); + + private static string GetOsType() => + OperatingSystem.IsWindows() ? "windows" + : OperatingSystem.IsLinux() ? "linux" + : OperatingSystem.IsMacOS() ? "darwin" + : TelemetryDefaults.Unknown; + + private static void ConfigureTracingExporters( + TracerProviderBuilder builder, + IReadOnlyList otlpEndpoints, + bool enableConsoleExporter + ) + { + foreach (string endpoint in otlpEndpoints) + { + builder.AddOtlpExporter(options => options.Endpoint = new Uri(endpoint)); + } + + if (enableConsoleExporter) + builder.AddConsoleExporter(); + } + + private static void ConfigureMetricExporters( + MeterProviderBuilder builder, + IReadOnlyList otlpEndpoints, + bool enableConsoleExporter + ) + { + foreach (string endpoint in otlpEndpoints) + { + builder.AddOtlpExporter(options => options.Endpoint = new Uri(endpoint)); + } + + if (enableConsoleExporter) + builder.AddConsoleExporter(); + } } diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/SharedServiceRegistration.cs b/src/SharedKernel/SharedKernel.Api/Extensions/SharedServiceRegistration.cs index 8ea4abff..74133dee 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/SharedServiceRegistration.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/SharedServiceRegistration.cs @@ -1,13 +1,17 @@ using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using SharedKernel.Api.Filters.Validation; using SharedKernel.Api.Security; +using SharedKernel.Application.Batch.Rules; using SharedKernel.Application.Context; using SharedKernel.Application.Options; using SharedKernel.Domain.Interfaces; +using SharedKernel.Infrastructure.Observability; using SharedKernel.Infrastructure.Persistence.Auditing; using SharedKernel.Infrastructure.Persistence.SoftDelete; using SharedKernel.Infrastructure.Persistence.UnitOfWork; @@ -55,6 +59,10 @@ IConfiguration configuration services.AddScoped(); services.AddSingleton(TimeProvider.System); + // Validation metrics (IValidationMetrics → ValidationTelemetry) + services.AddSingleton(); + services.AddTransient(typeof(FluentValidationBatchRule<>)); + // Exception handling & ProblemDetails (RFC 7807) services.AddSharedApiErrorHandling(); @@ -74,4 +82,18 @@ IConfiguration configuration return services; } + + /// + /// Registers MVC controllers with shared global filters (FluentValidation, etc.). + /// Use this instead of AddControllers() in every API host. + /// + public static IMvcBuilder AddSharedControllers(this IServiceCollection services) + { + services.AddScoped(); + + return services.AddControllers(options => + { + options.Filters.AddService(); + }); + } } diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs index 2f9202b4..aaceff76 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs @@ -1,4 +1,6 @@ +using HealthChecks.UI.Client; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; namespace SharedKernel.Api.Extensions; @@ -36,7 +38,21 @@ bool useOutputCaching app.UseSharedOutputCaching(); app.MapSharedOpenApiEndpoint(); - app.MapHealthChecks("/health").AllowAnonymous(); + app.MapSharedHealthChecks(); + + return app; + } + + public static WebApplication MapSharedHealthChecks(this WebApplication app) + { + app.MapHealthChecks( + "/health", + new HealthCheckOptions + { + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse, + } + ) + .AllowAnonymous(); return app; } diff --git a/src/SharedKernel/SharedKernel.Api/Filters/Validation/FluentValidationActionFilter.cs b/src/SharedKernel/SharedKernel.Api/Filters/Validation/FluentValidationActionFilter.cs index 89af7ba8..864a2350 100644 --- a/src/SharedKernel/SharedKernel.Api/Filters/Validation/FluentValidationActionFilter.cs +++ b/src/SharedKernel/SharedKernel.Api/Filters/Validation/FluentValidationActionFilter.cs @@ -1,6 +1,7 @@ using FluentValidation; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.Filters.Validation; @@ -50,6 +51,7 @@ ActionExecutionDelegate next if (result.IsValid) continue; + ValidationTelemetry.RecordFromActionFilter(context, argumentType, result.Errors); foreach (FluentValidation.Results.ValidationFailure error in result.Errors) context.ModelState.AddModelError(error.PropertyName, error.ErrorMessage); } diff --git a/src/SharedKernel/SharedKernel.Api/OutputCaching/OutputCacheInvalidationService.cs b/src/SharedKernel/SharedKernel.Api/OutputCaching/OutputCacheInvalidationService.cs index 6f1e390c..466c038a 100644 --- a/src/SharedKernel/SharedKernel.Api/OutputCaching/OutputCacheInvalidationService.cs +++ b/src/SharedKernel/SharedKernel.Api/OutputCaching/OutputCacheInvalidationService.cs @@ -1,5 +1,7 @@ +using System.Diagnostics; using Microsoft.AspNetCore.OutputCaching; using Microsoft.Extensions.Logging; +using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.OutputCaching; @@ -27,9 +29,15 @@ public async Task EvictAsync( { foreach (string tag in tags.Distinct(StringComparer.Ordinal)) { + long startedAt = Stopwatch.GetTimestamp(); try { + using Activity? activity = CacheTelemetry.StartOutputCacheInvalidationActivity(tag); await _store.EvictByTagAsync(tag, cancellationToken); + CacheTelemetry.RecordOutputCacheInvalidation( + tag, + Stopwatch.GetElapsedTime(startedAt) + ); } catch (Exception ex) when (ex is not OperationCanceledException) { diff --git a/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs b/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs index f45eadd5..c8426115 100644 --- a/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs +++ b/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.OutputCaching; using SharedKernel.Application.Security; +using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.OutputCaching; @@ -38,6 +39,7 @@ CancellationToken cancellationToken context.EnableOutputCaching = true; context.AllowCacheLookup = true; context.AllowCacheStorage = true; + CacheTelemetry.ConfigureRequest(context); string tenantId = context.HttpContext.User.FindFirstValue(SharedAuthConstants.Claims.TenantId) @@ -59,10 +61,18 @@ CancellationToken cancellationToken public ValueTask ServeFromCacheAsync( OutputCacheContext context, CancellationToken cancellationToken - ) => ValueTask.CompletedTask; + ) + { + CacheTelemetry.RecordCacheHit(context); + return ValueTask.CompletedTask; + } public ValueTask ServeResponseAsync( OutputCacheContext context, CancellationToken cancellationToken - ) => ValueTask.CompletedTask; + ) + { + CacheTelemetry.RecordResponseOutcome(context); + return ValueTask.CompletedTask; + } } diff --git a/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj b/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj index ff538327..af2a1f05 100644 --- a/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj +++ b/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj @@ -1,8 +1,10 @@ + + @@ -18,6 +20,9 @@ + + + @@ -32,6 +37,7 @@ + diff --git a/src/SharedKernel/SharedKernel.Application/Batch/Rules/FluentValidationBatchRule.cs b/src/SharedKernel/SharedKernel.Application/Batch/Rules/FluentValidationBatchRule.cs index 0063c700..fd1434f5 100644 --- a/src/SharedKernel/SharedKernel.Application/Batch/Rules/FluentValidationBatchRule.cs +++ b/src/SharedKernel/SharedKernel.Application/Batch/Rules/FluentValidationBatchRule.cs @@ -1,13 +1,14 @@ using FluentValidation; +using FluentValidation.Results; using SharedKernel.Domain.Entities.Contracts; namespace SharedKernel.Application.Batch.Rules; -public sealed class FluentValidationBatchRule(IValidator validator) - : IBatchRule +public sealed class FluentValidationBatchRule( + IValidator validator, + IValidationMetrics metrics +) : IBatchRule { - private readonly IValidator _validator = validator; - public async Task ApplyAsync(BatchFailureContext context, CancellationToken ct) { for (int i = 0; i < context.Items.Count; i++) @@ -15,8 +16,7 @@ public async Task ApplyAsync(BatchFailureContext context, CancellationTok if (context.IsFailed(i)) continue; - FluentValidation.Results.ValidationResult validationResult = - await _validator.ValidateAsync(context.Items[i], ct); + ValidationResult validationResult = await validator.ValidateAsync(context.Items[i], ct); if (!validationResult.IsValid) { Guid? id = context.Items[i] is IHasId hasId ? hasId.Id : null; @@ -25,6 +25,8 @@ public async Task ApplyAsync(BatchFailureContext context, CancellationTok id, validationResult.Errors.Select(error => error.ErrorMessage).ToList() ); + + metrics.RecordFailure(typeof(TItem).Name, typeof(TItem), validationResult.Errors); } } } diff --git a/src/SharedKernel/SharedKernel.Application/Batch/Rules/IValidationMetrics.cs b/src/SharedKernel/SharedKernel.Application/Batch/Rules/IValidationMetrics.cs new file mode 100644 index 00000000..a63541ec --- /dev/null +++ b/src/SharedKernel/SharedKernel.Application/Batch/Rules/IValidationMetrics.cs @@ -0,0 +1,12 @@ +using FluentValidation.Results; + +namespace SharedKernel.Application.Batch.Rules; + +/// +/// Abstraction for recording validation failure metrics. Implemented in the infrastructure +/// layer so the application layer stays free of telemetry dependencies. +/// +public interface IValidationMetrics +{ + void RecordFailure(string source, Type argumentType, IReadOnlyList failures); +} diff --git a/src/SharedKernel/SharedKernel.Application/Middleware/ErrorOrValidationMiddleware.cs b/src/SharedKernel/SharedKernel.Application/Middleware/ErrorOrValidationMiddleware.cs deleted file mode 100644 index 2eb99a0c..00000000 --- a/src/SharedKernel/SharedKernel.Application/Middleware/ErrorOrValidationMiddleware.cs +++ /dev/null @@ -1,53 +0,0 @@ -using ErrorOr; -using FluentValidation; -using SharedKernel.Application.Errors; -using Wolverine; - -namespace SharedKernel.Application.Middleware; - -/// -/// Wolverine handler middleware that validates incoming messages using FluentValidation -/// and short-circuits with errors instead of throwing exceptions. -/// Applied only to handlers whose return type is ErrorOr<T>. -/// -public static class ErrorOrValidationMiddleware -{ - /// - /// Runs FluentValidation before the handler executes. If validation fails, - /// returns with validation errors - /// so the handler is never invoked. - /// - public static async Task<(HandlerContinuation, ErrorOr)> BeforeAsync< - TMessage, - TResponse - >(TMessage message, IValidator? validator = null, CancellationToken ct = default) - { - if (validator is null) - return (HandlerContinuation.Continue, default!); - - FluentValidation.Results.ValidationResult validationResult = await validator.ValidateAsync( - message, - ct - ); - - if (validationResult.IsValid) - return (HandlerContinuation.Continue, default!); - - List errors = validationResult - .Errors.Select(e => - { - Dictionary metadata = new() { ["propertyName"] = e.PropertyName }; - if (e.AttemptedValue is not null) - metadata["attemptedValue"] = e.AttemptedValue; - - return Error.Validation( - code: ErrorCatalog.General.ValidationFailed, - description: e.ErrorMessage, - metadata: metadata - ); - }) - .ToList(); - - return (HandlerContinuation.Stop, errors); - } -} diff --git a/src/SharedKernel/SharedKernel.Application/Options/ObservabilityOptions.cs b/src/SharedKernel/SharedKernel.Application/Options/ObservabilityOptions.cs new file mode 100644 index 00000000..47d5b8ea --- /dev/null +++ b/src/SharedKernel/SharedKernel.Application/Options/ObservabilityOptions.cs @@ -0,0 +1,39 @@ +namespace SharedKernel.Application.Options; + +/// +/// Root configuration object for observability exporters and endpoints. +/// +public sealed class ObservabilityOptions +{ + public const string SectionName = "Observability"; + + public OtlpEndpointOptions Otlp { get; init; } = new(); + + public AspireEndpointOptions Aspire { get; init; } = new(); + + public ObservabilityExportersOptions Exporters { get; init; } = new(); +} + +public sealed class OtlpEndpointOptions +{ + public string Endpoint { get; init; } = string.Empty; +} + +public sealed class AspireEndpointOptions +{ + public string Endpoint { get; init; } = string.Empty; +} + +public sealed class ObservabilityExportersOptions +{ + public ObservabilityExporterToggleOptions Aspire { get; init; } = new(); + + public ObservabilityExporterToggleOptions Otlp { get; init; } = new(); + + public ObservabilityExporterToggleOptions Console { get; init; } = new(); +} + +public sealed class ObservabilityExporterToggleOptions +{ + public bool? Enabled { get; init; } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/AuthTelemetry.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/AuthTelemetry.cs new file mode 100644 index 00000000..4ff47b0f --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/AuthTelemetry.cs @@ -0,0 +1,70 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.AspNetCore.Http; + +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Authentication-related telemetry facade for shared HTTP authentication flows. +/// +public static class AuthTelemetry +{ + private static readonly ActivitySource ActivitySource = new( + ObservabilityConventions.ActivitySourceName + ); + private static readonly Meter Meter = new(ObservabilityConventions.MeterName); + + private static readonly Counter AuthFailures = Meter.CreateCounter( + TelemetryMetricNames.AuthFailures + ); + + public static void RecordMissingTenantClaim(HttpContext httpContext, string scheme) => + RecordFailure( + TelemetryActivityNames.TokenValidated, + scheme, + TelemetryFailureReasons.MissingTenantClaim, + TelemetryApiSurfaceResolver.Resolve(httpContext.Request.Path) + ); + + public static void RecordAuthenticationFailed( + HttpContext httpContext, + string scheme, + Exception exception + ) => + RecordFailure( + TelemetryActivityNames.TokenValidated, + scheme, + TelemetryFailureReasons.AuthenticationFailed, + TelemetryApiSurfaceResolver.Resolve(httpContext.Request.Path), + exception + ); + + private static void RecordFailure( + string activityName, + string scheme, + string reason, + string surface, + Exception? exception = null + ) + { + AuthFailures.Add( + 1, + [ + new KeyValuePair(TelemetryTagKeys.AuthScheme, scheme), + new KeyValuePair(TelemetryTagKeys.AuthFailureReason, reason), + new KeyValuePair(TelemetryTagKeys.ApiSurface, surface), + ] + ); + + using Activity? activity = ActivitySource.StartActivity( + activityName, + ActivityKind.Internal + ); + activity?.SetTag(TelemetryTagKeys.AuthScheme, scheme); + activity?.SetTag(TelemetryTagKeys.AuthFailureReason, reason); + activity?.SetTag(TelemetryTagKeys.ApiSurface, surface); + activity?.SetStatus(ActivityStatusCode.Error); + if (exception is not null) + activity?.SetTag(TelemetryTagKeys.ExceptionType, exception.GetType().Name); + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/CacheTelemetry.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/CacheTelemetry.cs new file mode 100644 index 00000000..a5c7c887 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/CacheTelemetry.cs @@ -0,0 +1,104 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OutputCaching; + +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Output-cache telemetry facade for invalidation activities and cache outcome metrics. +/// +public static class CacheTelemetry +{ + private static readonly ActivitySource ActivitySource = new( + ObservabilityConventions.ActivitySourceName + ); + private static readonly Meter Meter = new(ObservabilityConventions.MeterName); + + private static readonly Counter OutputCacheInvalidations = Meter.CreateCounter( + TelemetryMetricNames.OutputCacheInvalidations + ); + + private static readonly Histogram OutputCacheInvalidationDurationMs = + Meter.CreateHistogram( + TelemetryMetricNames.OutputCacheInvalidationDuration, + unit: "ms" + ); + + private static readonly Counter OutputCacheOutcomes = Meter.CreateCounter( + TelemetryMetricNames.OutputCacheOutcomes + ); + + public static Activity? StartOutputCacheInvalidationActivity(string tag) + { + Activity? activity = ActivitySource.StartActivity( + TelemetryActivityNames.OutputCacheInvalidate, + ActivityKind.Internal + ); + activity?.SetTag(TelemetryTagKeys.CacheTag, tag); + return activity; + } + + public static void RecordOutputCacheInvalidation(string tag, TimeSpan duration) + { + TagList tags = new() { { TelemetryTagKeys.CacheTag, tag } }; + OutputCacheInvalidations.Add(1, tags); + OutputCacheInvalidationDurationMs.Record(duration.TotalMilliseconds, tags); + } + + public static void ConfigureRequest(OutputCacheContext context) + { + context.HttpContext.Items[TelemetryContextKeys.OutputCachePolicyName] = ResolvePolicyName( + context + ); + } + + public static void RecordCacheHit(OutputCacheContext context) => + RecordCacheOutcome(context, TelemetryOutcomeValues.Hit); + + public static void RecordResponseOutcome(OutputCacheContext context) + { + string outcome = context.AllowCacheStorage + ? TelemetryOutcomeValues.Store + : TelemetryOutcomeValues.Bypass; + RecordCacheOutcome(context, outcome); + } + + private static void RecordCacheOutcome(OutputCacheContext context, string outcome) + { + OutputCacheOutcomes.Add( + 1, + [ + new KeyValuePair( + TelemetryTagKeys.CachePolicy, + ResolvePolicyName(context) + ), + new KeyValuePair( + TelemetryTagKeys.ApiSurface, + TelemetryApiSurfaceResolver.Resolve(context.HttpContext.Request.Path) + ), + new KeyValuePair(TelemetryTagKeys.CacheOutcome, outcome), + ] + ); + } + + private static string ResolvePolicyName(OutputCacheContext context) + { + if ( + context.HttpContext.Items.TryGetValue( + TelemetryContextKeys.OutputCachePolicyName, + out object? cached + ) && cached is string name + ) + { + return name; + } + + return context + .HttpContext.GetEndpoint() + ?.Metadata.OfType() + .Select(attribute => attribute.PolicyName) + .FirstOrDefault(policyName => !string.IsNullOrWhiteSpace(policyName)) + ?? TelemetryDefaults.Default; + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/ConflictTelemetry.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ConflictTelemetry.cs new file mode 100644 index 00000000..2f3a601a --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ConflictTelemetry.cs @@ -0,0 +1,37 @@ +using System.Diagnostics.Metrics; +using SharedKernel.Domain.Exceptions; + +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Conflict-related metrics facade. +/// +public static class ConflictTelemetry +{ + private static readonly Meter Meter = new(ObservabilityConventions.MeterName); + + private static readonly Counter ConcurrencyConflicts = Meter.CreateCounter( + TelemetryMetricNames.ConcurrencyConflicts + ); + + private static readonly Counter DomainConflicts = Meter.CreateCounter( + TelemetryMetricNames.DomainConflicts + ); + + public static void Record(Exception exception, string errorCode) + { + if (exception is Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException) + { + ConcurrencyConflicts.Add(1); + return; + } + + if (exception is ConflictException) + { + DomainConflicts.Add( + 1, + [new KeyValuePair(TelemetryTagKeys.ErrorCode, errorCode)] + ); + } + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckConventions.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckConventions.cs new file mode 100644 index 00000000..e8603aea --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckConventions.cs @@ -0,0 +1,24 @@ +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Shared names and tags for infrastructure health checks exposed by microservice hosts. +/// +public static class HealthCheckNames +{ + public const string PostgreSql = "postgres"; + public const string MongoDb = "mongo"; + public const string Dragonfly = "dragonfly"; + public const string RabbitMq = "rabbitmq"; + public const string Scheduler = "scheduler"; +} + +/// +/// Common tags used to classify health checks by dependency type. +/// +public static class HealthCheckTags +{ + public static readonly string[] Database = ["database", "infrastructure"]; + public static readonly string[] Cache = ["cache", "infrastructure"]; + public static readonly string[] Messaging = ["messaging", "infrastructure"]; + public static readonly string[] Scheduler = ["scheduler", "infrastructure"]; +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs new file mode 100644 index 00000000..d0d17425 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs @@ -0,0 +1,46 @@ +using System.Collections.Concurrent; +using System.Diagnostics.Metrics; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Publishes health check results as observable gauge metrics. +/// +public sealed class HealthCheckMetricsPublisher : IHealthCheckPublisher +{ + private static readonly Meter Meter = new(ObservabilityConventions.HealthMeterName); + private static readonly ConcurrentDictionary Statuses = new( + StringComparer.OrdinalIgnoreCase + ); + + private readonly ObservableGauge _gauge; + + public HealthCheckMetricsPublisher() + { + _gauge = Meter.CreateObservableGauge(TelemetryMetricNames.HealthStatus, ObserveStatuses); + } + + public Task PublishAsync(HealthReport report, CancellationToken cancellationToken) + { + foreach ((string key, HealthReportEntry value) in report.Entries) + { + Statuses[key] = value.Status == HealthStatus.Healthy ? 1 : 0; + } + + return Task.CompletedTask; + } + + internal static IReadOnlyDictionary SnapshotStatuses() => Statuses; + + private static IEnumerable> ObserveStatuses() + { + foreach ((string key, int value) in Statuses) + { + yield return new Measurement( + value, + new KeyValuePair(TelemetryTagKeys.Service, key) + ); + } + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/HttpRouteResolver.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HttpRouteResolver.cs new file mode 100644 index 00000000..84316392 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HttpRouteResolver.cs @@ -0,0 +1,44 @@ +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Resolves the normalized route template for the current HTTP request. +/// +public static partial class HttpRouteResolver +{ + [GeneratedRegex( + @"\{version(?::[^}]*)?\}", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant + )] + private static partial Regex VersionTokenRegex(); + + public static string Resolve(HttpContext httpContext) + { + string? routeTemplate = httpContext.GetEndpoint() is RouteEndpoint routeEndpoint + ? routeEndpoint.RoutePattern.RawText + : null; + + if (string.IsNullOrWhiteSpace(routeTemplate)) + return httpContext.Request.Path.Value ?? TelemetryDefaults.Unknown; + + return ReplaceVersionToken(routeTemplate, httpContext.Request.RouteValues); + } + + public static string ReplaceVersionToken(string routeTemplate, RouteValueDictionary routeValues) + { + if (string.IsNullOrWhiteSpace(routeTemplate)) + return TelemetryDefaults.Unknown; + + if (!routeValues.TryGetValue("version", out object? versionValue) || versionValue is null) + return routeTemplate; + + string? version = versionValue.ToString(); + if (string.IsNullOrWhiteSpace(version)) + return routeTemplate; + + return VersionTokenRegex().Replace(routeTemplate, version, 1); + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/ObservabilityConventions.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ObservabilityConventions.cs index f564c594..19024be4 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/Observability/ObservabilityConventions.cs +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ObservabilityConventions.cs @@ -1,11 +1,74 @@ namespace SharedKernel.Infrastructure.Observability; +/// Shared names for the application's OpenTelemetry activity source and meters. +public static class ObservabilityConventions +{ + public const string ActivitySourceName = "APITemplate"; + public const string MeterName = "APITemplate"; + public const string HealthMeterName = "APITemplate.Health"; +} + +/// Canonical metric instrument names emitted by the application meter. +public static class TelemetryMetricNames +{ + public const string AuthFailures = "apitemplate_auth_failures"; + public const string ConcurrencyConflicts = "apitemplate_concurrency_conflicts"; + public const string DomainConflicts = "apitemplate_domain_conflicts"; + public const string HandledExceptions = "apitemplate_exceptions_handled"; + public const string HealthStatus = "apitemplate_healthcheck_status"; + public const string OutputCacheInvalidations = "apitemplate_output_cache_invalidations"; + public const string OutputCacheInvalidationDuration = + "apitemplate_output_cache_invalidation_duration"; + public const string OutputCacheOutcomes = "apitemplate_output_cache_outcomes"; + public const string ValidationErrors = "apitemplate_validation_errors"; + public const string ValidationRequestsRejected = "apitemplate_validation_requests_rejected"; +} + /// Canonical tag/attribute key names applied to metrics and traces. public static class TelemetryTagKeys { public const string ApiSurface = "apitemplate.api.surface"; public const string Authenticated = "apitemplate.authenticated"; + public const string AuthFailureReason = "auth.failure_reason"; + public const string AuthScheme = "auth.scheme"; + public const string CacheOutcome = "cache.outcome"; + public const string CachePolicy = "cache.policy"; + public const string CacheTag = "cache.tag"; + public const string DbSystem = "db.system"; + public const string ErrorCode = "error.code"; + public const string ExceptionType = "exception.type"; + public const string HttpRoute = "http.route"; + public const string Service = "service"; + public const string StartupComponent = "startup.component"; + public const string StartupStep = "startup.step"; + public const string StartupSuccess = "startup.success"; public const string TenantId = "tenant.id"; + public const string ValidationDtoType = "validation.dto_type"; + public const string ValidationProperty = "validation.property"; +} + +/// Canonical activity/span names recorded in the application activity source. +public static class TelemetryActivityNames +{ + public const string OutputCacheInvalidate = "output_cache.invalidate"; + public const string TokenValidated = "auth.token-validated"; + + public static string Startup(string step) => $"startup.{step}"; +} + +/// Well-known output-cache outcome values. +public static class TelemetryOutcomeValues +{ + public const string Bypass = "bypass"; + public const string Hit = "hit"; + public const string Store = "store"; +} + +/// Well-known authentication failure reasons. +public static class TelemetryFailureReasons +{ + public const string AuthenticationFailed = "authentication_failed"; + public const string MissingTenantClaim = "missing_tenant_claim"; } /// Well-known tag values that identify the API surface a request was served from. @@ -17,6 +80,20 @@ public static class TelemetrySurfaces public const string Rest = "rest"; } +/// Default fallback values used when a tag or setting cannot be resolved. +public static class TelemetryDefaults +{ + public const string AspireOtlpEndpoint = "http://localhost:4317"; + public const string Default = "default"; + public const string Unknown = "unknown"; +} + +/// Keys used to store transient telemetry values in . +public static class TelemetryContextKeys +{ + public const string OutputCachePolicyName = "OutputCachePolicyName"; +} + /// URL path prefixes used to classify requests into API surface areas. public static class TelemetryPathPrefixes { @@ -25,3 +102,98 @@ public static class TelemetryPathPrefixes public const string OpenApi = "/openapi"; public const string Scalar = "/scalar"; } + +/// Well-known step names used to identify individual startup task activities. +public static class TelemetryStartupSteps +{ + public const string Migrate = "migrate"; +} + +/// Well-known component names tagged on startup activity spans. +public static class TelemetryStartupComponents +{ + public const string PostgreSql = "postgresql"; +} + +/// Well-known database system tag values. +public static class TelemetryDatabaseSystems +{ + public const string PostgreSql = "postgresql"; +} + +/// Meter names from ASP.NET Core and Microsoft libraries used to subscribe to built-in metrics. +public static class TelemetryMeterNames +{ + public const string AspNetCoreAuthentication = "Microsoft.AspNetCore.Authentication"; + public const string AspNetCoreAuthorization = "Microsoft.AspNetCore.Authorization"; + public const string AspNetCoreConnections = "Microsoft.AspNetCore.Http.Connections"; + public const string AspNetCoreDiagnostics = "Microsoft.AspNetCore.Diagnostics"; + public const string AspNetCoreHosting = "Microsoft.AspNetCore.Hosting"; + public const string AspNetCoreRateLimiting = "Microsoft.AspNetCore.RateLimiting"; + public const string AspNetCoreRouting = "Microsoft.AspNetCore.Routing"; + public const string AspNetCoreServerKestrel = "Microsoft.AspNetCore.Server.Kestrel"; +} + +/// Semantic-convention instrument names for HTTP client and server request durations. +public static class TelemetryInstrumentNames +{ + public const string HttpClientRequestDuration = "http.client.request.duration"; + public const string HttpServerRequestDuration = "http.server.request.duration"; +} + +/// OpenTelemetry resource attribute key names. +public static class TelemetryResourceAttributeKeys +{ + public const string AssemblyName = "assembly.name"; + public const string DeploymentEnvironmentName = "deployment.environment.name"; + public const string HostArchitecture = "host.arch"; + public const string HostName = "host.name"; + public const string OsType = "os.type"; + public const string ProcessPid = "process.pid"; + public const string ProcessRuntimeName = "process.runtime.name"; + public const string ProcessRuntimeVersion = "process.runtime.version"; + public const string ServiceInstanceId = "service.instance.id"; + public const string ServiceName = "service.name"; + public const string ServiceNamespace = "service.namespace"; + public const string ServiceVersion = "service.version"; +} + +/// Pre-defined histogram bucket boundaries for common metric instruments. +public static class TelemetryHistogramBoundaries +{ + public static readonly double[] HttpRequestDurationSeconds = + [ + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1, + 2.5, + 5, + 10, + ]; + + public static readonly double[] CacheOperationDurationMs = + [ + 1, + 5, + 10, + 25, + 50, + 100, + 250, + 500, + 1000, + ]; +} + +/// Third-party library names used as OpenTelemetry activity sources and meters. +public static class TelemetryThirdPartySources +{ + public const string Wolverine = "Wolverine"; +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/StartupTelemetry.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/StartupTelemetry.cs new file mode 100644 index 00000000..9468fa08 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/StartupTelemetry.cs @@ -0,0 +1,51 @@ +using System.Diagnostics; + +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Startup-phase telemetry helper for shared startup tasks. +/// +public static class StartupTelemetry +{ + private static readonly ActivitySource ActivitySource = new( + ObservabilityConventions.ActivitySourceName + ); + + public static Scope StartRelationalMigration() => + StartStep( + TelemetryStartupSteps.Migrate, + TelemetryStartupComponents.PostgreSql, + TelemetryDatabaseSystems.PostgreSql + ); + + private static Scope StartStep(string step, string component, string? dbSystem = null) + { + Activity? activity = ActivitySource.StartActivity( + TelemetryActivityNames.Startup(step), + ActivityKind.Internal + ); + activity?.SetTag(TelemetryTagKeys.StartupStep, step); + activity?.SetTag(TelemetryTagKeys.StartupComponent, component); + if (!string.IsNullOrWhiteSpace(dbSystem)) + activity?.SetTag(TelemetryTagKeys.DbSystem, dbSystem); + + return new Scope(activity); + } + + public sealed class Scope(Activity? activity) : IDisposable + { + private readonly Activity? _activity = activity; + + public void Fail(Exception exception) + { + if (_activity is null) + return; + + _activity.SetStatus(ActivityStatusCode.Error, exception.Message); + _activity.SetTag(TelemetryTagKeys.StartupSuccess, false); + _activity.SetTag(TelemetryTagKeys.ExceptionType, exception.GetType().Name); + } + + public void Dispose() => _activity?.Dispose(); + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/ValidationTelemetry.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ValidationTelemetry.cs new file mode 100644 index 00000000..176ecf9c --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ValidationTelemetry.cs @@ -0,0 +1,80 @@ +using System.Diagnostics.Metrics; +using FluentValidation.Results; +using Microsoft.AspNetCore.Mvc.Filters; +using SharedKernel.Application.Batch.Rules; + +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Validation-related metrics facade. Implements for DI use +/// and exposes a static HTTP overload for use in MVC action filters. +/// +public sealed class ValidationTelemetry : IValidationMetrics +{ + private static readonly Meter Meter = new(ObservabilityConventions.MeterName); + + private static readonly Counter ValidationRequestsRejected = Meter.CreateCounter( + TelemetryMetricNames.ValidationRequestsRejected + ); + + private static readonly Counter ValidationErrors = Meter.CreateCounter( + TelemetryMetricNames.ValidationErrors + ); + + /// + public void RecordFailure( + string source, + Type argumentType, + IReadOnlyList failures + ) + { + ValidationRequestsRejected.Add( + 1, + [ + new KeyValuePair( + TelemetryTagKeys.ValidationDtoType, + argumentType.Name + ), + new KeyValuePair(TelemetryTagKeys.HttpRoute, source), + ] + ); + + foreach (ValidationFailure failure in failures) + { + ValidationErrors.Add( + 1, + [ + new KeyValuePair( + TelemetryTagKeys.ValidationDtoType, + argumentType.Name + ), + new KeyValuePair(TelemetryTagKeys.HttpRoute, source), + new KeyValuePair( + TelemetryTagKeys.ValidationProperty, + failure.PropertyName + ), + ] + ); + } + } + + /// + /// Records a validation failure from an MVC action filter, resolving the route from + /// . + /// + public static void RecordFromActionFilter( + ActionExecutingContext context, + Type argumentType, + IEnumerable failures + ) + { + string route = context.ActionDescriptor.AttributeRouteInfo?.Template is { } template + ? HttpRouteResolver.ReplaceVersionToken(template, context.RouteData.Values) + : context.HttpContext.Request.Path.Value ?? TelemetryDefaults.Unknown; + + Instance.RecordFailure(route, argumentType, failures.ToList()); + } + + /// Singleton used by the static helper. + internal static readonly ValidationTelemetry Instance = new(); +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj b/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj index 87828d54..6d68c6aa 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj +++ b/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj @@ -1,6 +1,7 @@ + diff --git a/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs b/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs index 9f6e86a6..07a4ddb1 100644 --- a/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs +++ b/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs @@ -21,14 +21,16 @@ public static WolverineOptions UseSharedRabbitMq( IConfiguration configuration ) { - string connectionString = - configuration.GetConnectionString("RabbitMQ") ?? BuildFromHostName(configuration); + string connectionString = ResolveConnectionString(configuration); opts.UseRabbitMq(new Uri(connectionString)).AutoProvision().EnableWolverineControlQueues(); return opts; } + internal static string ResolveConnectionString(IConfiguration configuration) => + configuration.GetConnectionString("RabbitMQ") ?? BuildFromHostName(configuration); + private static string BuildFromHostName(IConfiguration configuration) { string? hostName = configuration["RabbitMQ:HostName"]; diff --git a/src/SharedKernel/SharedKernel.Messaging/HealthChecks/RabbitMqHealthCheck.cs b/src/SharedKernel/SharedKernel.Messaging/HealthChecks/RabbitMqHealthCheck.cs new file mode 100644 index 00000000..e03f7ac2 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Messaging/HealthChecks/RabbitMqHealthCheck.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using RabbitMQ.Client; +using SharedKernel.Messaging.Conventions; + +namespace SharedKernel.Messaging.HealthChecks; + +/// +/// Verifies that the configured RabbitMQ broker is reachable and accepts AMQP connections. +/// +public sealed class RabbitMqHealthCheck(IConfiguration configuration) : IHealthCheck +{ + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default + ) + { + string connectionString; + try + { + connectionString = RabbitMqConventionExtensions.ResolveConnectionString(configuration); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("RabbitMQ configuration is invalid.", ex); + } + + try + { + ConnectionFactory factory = new() { Uri = new Uri(connectionString) }; + await using IConnection connection = await factory.CreateConnectionAsync(); + + return connection.IsOpen + ? HealthCheckResult.Healthy() + : HealthCheckResult.Unhealthy("RabbitMQ connection was created but is not open."); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("RabbitMQ broker is unavailable.", ex); + } + } +} diff --git a/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj b/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj index 539302c8..108382f6 100644 --- a/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj +++ b/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj @@ -4,12 +4,20 @@ + + + + net10.0 enable enable + + + + diff --git a/tests/ProductCatalog.Tests/Health/MongoDbHealthCheckTests.cs b/tests/ProductCatalog.Tests/Health/MongoDbHealthCheckTests.cs new file mode 100644 index 00000000..20918fc0 --- /dev/null +++ b/tests/ProductCatalog.Tests/Health/MongoDbHealthCheckTests.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Moq; +using ProductCatalog.Api.Health; +using ProductCatalog.Infrastructure.Persistence; +using Shouldly; +using Xunit; + +namespace ProductCatalog.Tests.Health; + +public sealed class MongoDbHealthCheckTests +{ + [Fact] + public async Task CheckHealthAsync_WhenPingSucceeds_ReturnsHealthy() + { + Mock probe = new(); + MongoDbHealthCheck sut = new(probe.Object); + + HealthCheckResult result = await sut.CheckHealthAsync(new HealthCheckContext()); + + result.Status.ShouldBe(HealthStatus.Healthy); + } + + [Fact] + public async Task CheckHealthAsync_WhenPingFails_ReturnsUnhealthy() + { + Mock probe = new(); + probe + .Setup(x => x.PingAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("boom")); + MongoDbHealthCheck sut = new(probe.Object); + + HealthCheckResult result = await sut.CheckHealthAsync(new HealthCheckContext()); + + result.Status.ShouldBe(HealthStatus.Unhealthy); + } +} diff --git a/tests/SharedKernel.Tests/Middleware/ErrorOrValidationMiddlewareTests.cs b/tests/SharedKernel.Tests/Middleware/ErrorOrValidationMiddlewareTests.cs deleted file mode 100644 index 00271158..00000000 --- a/tests/SharedKernel.Tests/Middleware/ErrorOrValidationMiddlewareTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using ErrorOr; -using FluentValidation; -using FluentValidation.Results; -using SharedKernel.Application.Errors; -using SharedKernel.Application.Middleware; -using Shouldly; -using Wolverine; -using Xunit; - -namespace SharedKernel.Tests.Middleware; - -public sealed class ErrorOrValidationMiddlewareTests -{ - [Fact] - public async Task BeforeAsync_WhenNoValidator_ReturnsContinue() - { - TestCommand message = new("valid"); - - (HandlerContinuation continuation, ErrorOr response) = - await ErrorOrValidationMiddleware.BeforeAsync(message); - - continuation.ShouldBe(HandlerContinuation.Continue); - } - - [Fact] - public async Task BeforeAsync_WhenValidationPasses_ReturnsContinue() - { - TestCommand message = new("valid"); - PassingValidator validator = new(); - - (HandlerContinuation continuation, ErrorOr response) = - await ErrorOrValidationMiddleware.BeforeAsync(message, validator); - - continuation.ShouldBe(HandlerContinuation.Continue); - } - - [Fact] - public async Task BeforeAsync_WhenValidationFails_ReturnsStopWithErrors() - { - TestCommand message = new(""); - FailingValidator validator = new(); - - (HandlerContinuation continuation, ErrorOr response) = - await ErrorOrValidationMiddleware.BeforeAsync(message, validator); - - continuation.ShouldBe(HandlerContinuation.Stop); - response.IsError.ShouldBeTrue(); - response.Errors.Count.ShouldBe(1); - response.FirstError.Code.ShouldBe(ErrorCatalog.General.ValidationFailed); - } - - [Fact] - public async Task BeforeAsync_WhenValidationFails_ErrorContainsPropertyNameMetadata() - { - TestCommand message = new(""); - FailingValidator validator = new(); - - (HandlerContinuation _, ErrorOr response) = - await ErrorOrValidationMiddleware.BeforeAsync(message, validator); - - response.FirstError.Metadata.ShouldContainKey("propertyName"); - response.FirstError.Metadata["propertyName"].ShouldBe("Value"); - } - - [Fact] - public async Task BeforeAsync_WhenValidationFails_ErrorContainsAttemptedValueMetadata() - { - TestCommand message = new(""); - FailingValidator validator = new(); - - (HandlerContinuation _, ErrorOr response) = - await ErrorOrValidationMiddleware.BeforeAsync(message, validator); - - response.FirstError.Metadata.ShouldContainKey("attemptedValue"); - response.FirstError.Metadata["attemptedValue"].ShouldBe(""); - } - - public sealed record TestCommand(string Value); - - private sealed class PassingValidator : AbstractValidator - { - public PassingValidator() - { - // No rules - always passes - } - } - - private sealed class FailingValidator : AbstractValidator - { - public FailingValidator() - { - RuleFor(x => x.Value).NotEmpty().WithMessage("Value is required."); - } - } -} diff --git a/tests/SharedKernel.Tests/Observability/CacheTelemetryTests.cs b/tests/SharedKernel.Tests/Observability/CacheTelemetryTests.cs new file mode 100644 index 00000000..3418d5f5 --- /dev/null +++ b/tests/SharedKernel.Tests/Observability/CacheTelemetryTests.cs @@ -0,0 +1,18 @@ +using SharedKernel.Infrastructure.Observability; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Observability; + +public sealed class CacheTelemetryTests +{ + [Fact] + public void StartOutputCacheInvalidationActivity_AddsCacheTag() + { + using System.Diagnostics.Activity? activity = + CacheTelemetry.StartOutputCacheInvalidationActivity("Products"); + + activity.ShouldNotBeNull(); + activity.GetTagItem(TelemetryTagKeys.CacheTag).ShouldBe("Products"); + } +} diff --git a/tests/SharedKernel.Tests/Observability/HealthCheckMetricsPublisherTests.cs b/tests/SharedKernel.Tests/Observability/HealthCheckMetricsPublisherTests.cs new file mode 100644 index 00000000..06b290d5 --- /dev/null +++ b/tests/SharedKernel.Tests/Observability/HealthCheckMetricsPublisherTests.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SharedKernel.Infrastructure.Observability; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Observability; + +public sealed class HealthCheckMetricsPublisherTests +{ + [Fact] + public async Task PublishAsync_StoresLatestHealthStatuses() + { + HealthCheckMetricsPublisher publisher = new(); + HealthReport report = new( + new Dictionary + { + ["postgresql"] = new(HealthStatus.Healthy, "ok", TimeSpan.Zero, null, null), + ["redis"] = new(HealthStatus.Unhealthy, "down", TimeSpan.Zero, null, null), + }, + TimeSpan.Zero + ); + + await publisher.PublishAsync(report, TestContext.Current.CancellationToken); + + IReadOnlyDictionary statuses = HealthCheckMetricsPublisher.SnapshotStatuses(); + statuses["postgresql"].ShouldBe(1); + statuses["redis"].ShouldBe(0); + } +} diff --git a/tests/SharedKernel.Tests/Observability/HealthChecksServiceCollectionExtensionsTests.cs b/tests/SharedKernel.Tests/Observability/HealthChecksServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..75b0bc6a --- /dev/null +++ b/tests/SharedKernel.Tests/Observability/HealthChecksServiceCollectionExtensionsTests.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using SharedKernel.Api.Extensions; +using SharedKernel.Infrastructure.Observability; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Observability; + +public sealed class HealthChecksServiceCollectionExtensionsTests +{ + [Fact] + public void AddDragonflyHealthCheck_WhenConnectionStringMissing_DoesNotRegisterCheck() + { + ServiceCollection services = new(); + services.AddOptions(); + + services.AddHealthChecks().AddDragonflyHealthCheck(null); + + HealthCheckServiceOptions options = services + .BuildServiceProvider() + .GetRequiredService>() + .Value; + + options.Registrations.ShouldBeEmpty(); + } + + [Fact] + public void AddPostgreSqlHealthCheck_RegistersExpectedName() + { + ServiceCollection services = new(); + services.AddOptions(); + + services + .AddHealthChecks() + .AddPostgreSqlHealthCheck( + "Host=localhost;Database=app;Username=postgres;Password=postgres" + ); + + HealthCheckServiceOptions options = services + .BuildServiceProvider() + .GetRequiredService>() + .Value; + + options.Registrations.Select(x => x.Name).ShouldContain(HealthCheckNames.PostgreSql); + } + + [Fact] + public void AddSharedRabbitMqHealthCheck_RegistersExpectedName() + { + ServiceCollection services = new(); + services.AddOptions(); + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["ConnectionStrings:RabbitMQ"] = "amqp://localhost:5672", + } + ) + .Build(); + + services.AddSingleton(configuration); + services.AddHealthChecks().AddSharedRabbitMqHealthCheck(configuration); + + HealthCheckServiceOptions options = services + .BuildServiceProvider() + .GetRequiredService>() + .Value; + + options.Registrations.Select(x => x.Name).ShouldContain(HealthCheckNames.RabbitMq); + } +} diff --git a/tests/SharedKernel.Tests/Observability/HttpRouteResolverTests.cs b/tests/SharedKernel.Tests/Observability/HttpRouteResolverTests.cs new file mode 100644 index 00000000..e6db7d8f --- /dev/null +++ b/tests/SharedKernel.Tests/Observability/HttpRouteResolverTests.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SharedKernel.Infrastructure.Observability; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Observability; + +public sealed class HttpRouteResolverTests +{ + [Fact] + public void ReplaceVersionToken_WhenRouteContainsApiVersionConstraint_ReplacesWithConcreteVersion() + { + string resolvedRoute = HttpRouteResolver.ReplaceVersionToken( + "api/v{version:apiVersion}/products", + new RouteValueDictionary { ["version"] = "1" } + ); + + resolvedRoute.ShouldBe("api/v1/products"); + } + + [Fact] + public void ReplaceVersionToken_WhenVersionMissing_LeavesTemplateUnchanged() + { + string resolvedRoute = HttpRouteResolver.ReplaceVersionToken( + "api/v{version:apiVersion}/products", + new RouteValueDictionary() + ); + + resolvedRoute.ShouldBe("api/v{version:apiVersion}/products"); + } + + [Fact] + public void Resolve_WhenEndpointTemplateMissing_FallsBackToRequestPath() + { + DefaultHttpContext httpContext = new(); + httpContext.Request.Path = "/api/v1/products"; + + string resolvedRoute = HttpRouteResolver.Resolve(httpContext); + + resolvedRoute.ShouldBe("/api/v1/products"); + } +} diff --git a/tests/SharedKernel.Tests/Observability/ObservabilityExtensionsTests.cs b/tests/SharedKernel.Tests/Observability/ObservabilityExtensionsTests.cs new file mode 100644 index 00000000..0a33e3c1 --- /dev/null +++ b/tests/SharedKernel.Tests/Observability/ObservabilityExtensionsTests.cs @@ -0,0 +1,89 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using SharedKernel.Api.Extensions; +using SharedKernel.Application.Options; +using SharedKernel.Infrastructure.Observability; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Observability; + +public sealed class ObservabilityExtensionsTests +{ + [Fact] + public void GetEnabledOtlpEndpoints_WhenDevelopmentOutsideContainer_DefaultsToAspire() + { + ObservabilityOptions options = new(); + + IReadOnlyList endpoints = ObservabilityExtensions.GetEnabledOtlpEndpoints( + options, + new FakeHostEnvironment(Environments.Development) + ); + + endpoints.ShouldContain(TelemetryDefaults.AspireOtlpEndpoint); + } + + [Fact] + public void GetEnabledOtlpEndpoints_WhenExplicitOtlpEnabled_IncludesConfiguredEndpoint() + { + ObservabilityOptions options = new() + { + Otlp = new OtlpEndpointOptions { Endpoint = "http://alloy:4317" }, + Exporters = new ObservabilityExportersOptions + { + Otlp = new ObservabilityExporterToggleOptions { Enabled = true }, + Aspire = new ObservabilityExporterToggleOptions { Enabled = false }, + }, + }; + + IReadOnlyList endpoints = ObservabilityExtensions.GetEnabledOtlpEndpoints( + options, + new FakeHostEnvironment(Environments.Production) + ); + + endpoints.ShouldBe(["http://alloy:4317"]); + } + + [Fact] + public void BuildResourceAttributes_ReturnsExpectedMetadata() + { + Dictionary attributes = ObservabilityExtensions.BuildResourceAttributes( + "identity", + new FakeHostEnvironment(Environments.Development) + ); + + attributes[TelemetryResourceAttributeKeys.ServiceName].ShouldBe("identity"); + attributes.ShouldContainKey(TelemetryResourceAttributeKeys.ServiceVersion); + attributes.ShouldContainKey(TelemetryResourceAttributeKeys.ServiceInstanceId); + attributes.ShouldContainKey(TelemetryResourceAttributeKeys.HostName); + attributes.ShouldContainKey(TelemetryResourceAttributeKeys.HostArchitecture); + attributes.ShouldContainKey(TelemetryResourceAttributeKeys.OsType); + attributes.ShouldContainKey(TelemetryResourceAttributeKeys.ProcessRuntimeVersion); + } + + [Fact] + public void GetObservabilityOptions_WhenMissingConfiguration_ReturnsDefaults() + { + IConfiguration configuration = new ConfigurationBuilder().Build(); + + ObservabilityOptions options = ObservabilityExtensions.GetObservabilityOptions( + configuration + ); + + options.ShouldNotBeNull(); + options.Otlp.ShouldNotBeNull(); + options.Exporters.ShouldNotBeNull(); + } + + private sealed class FakeHostEnvironment(string environmentName) : IHostEnvironment + { + public string EnvironmentName { get; set; } = environmentName; + + public string ApplicationName { get; set; } = "SharedKernel.Tests"; + + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +} diff --git a/tests/SharedKernel.Tests/Observability/RabbitMqHealthCheckTests.cs b/tests/SharedKernel.Tests/Observability/RabbitMqHealthCheckTests.cs new file mode 100644 index 00000000..ed308be2 --- /dev/null +++ b/tests/SharedKernel.Tests/Observability/RabbitMqHealthCheckTests.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SharedKernel.Messaging.Conventions; +using SharedKernel.Messaging.HealthChecks; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Observability; + +public sealed class RabbitMqHealthCheckTests +{ + [Fact] + public void ResolveConnectionString_UsesExplicitConnectionString() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["ConnectionStrings:RabbitMQ"] = "amqp://guest:guest@broker:5672", + ["RabbitMQ:HostName"] = "legacy-host", + } + ) + .Build(); + + string connectionString = RabbitMqConventionExtensions.ResolveConnectionString( + configuration + ); + + connectionString.ShouldBe("amqp://guest:guest@broker:5672"); + } + + [Fact] + public void ResolveConnectionString_WhenOnlyLegacyHostConfigured_UsesHostFallback() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary { ["RabbitMQ:HostName"] = "rabbitmq:5672" } + ) + .Build(); + + string connectionString = RabbitMqConventionExtensions.ResolveConnectionString( + configuration + ); + + connectionString.ShouldBe("amqp://rabbitmq:5672"); + } + + [Fact] + public async Task CheckHealthAsync_WhenBrokerUnavailable_ReturnsUnhealthy() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["ConnectionStrings:RabbitMQ"] = "amqp://guest:guest@127.0.0.1:1", + } + ) + .Build(); + RabbitMqHealthCheck sut = new(configuration); + + HealthCheckResult result = await sut.CheckHealthAsync(new HealthCheckContext()); + + result.Status.ShouldBe(HealthStatus.Unhealthy); + } +} diff --git a/tests/SharedKernel.Tests/Observability/StartupTelemetryTests.cs b/tests/SharedKernel.Tests/Observability/StartupTelemetryTests.cs new file mode 100644 index 00000000..eee97de5 --- /dev/null +++ b/tests/SharedKernel.Tests/Observability/StartupTelemetryTests.cs @@ -0,0 +1,35 @@ +using System.Diagnostics; +using SharedKernel.Infrastructure.Observability; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Observability; + +public sealed class StartupTelemetryTests +{ + [Fact] + public void StartRelationalMigration_WhenFailed_SetsFailureTags() + { + using ActivityListener listener = new() + { + ShouldListenTo = source => source.Name == ObservabilityConventions.ActivitySourceName, + Sample = static (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllDataAndRecorded, + }; + ActivitySource.AddActivityListener(listener); + + using StartupTelemetry.Scope telemetry = StartupTelemetry.StartRelationalMigration(); + InvalidOperationException exception = new("boom"); + + telemetry.Fail(exception); + + Activity.Current.ShouldNotBeNull(); + Activity + .Current!.GetTagItem(TelemetryTagKeys.StartupStep) + .ShouldBe(TelemetryStartupSteps.Migrate); + Activity + .Current!.GetTagItem(TelemetryTagKeys.StartupComponent) + .ShouldBe(TelemetryStartupComponents.PostgreSql); + Activity.Current!.GetTagItem(TelemetryTagKeys.StartupSuccess).ShouldBe(false); + } +} From d9ebca4d80499970cbd49d600cf56af81a3774be Mon Sep 17 00:00:00 2001 From: Tadeas Zribko Date: Tue, 31 Mar 2026 01:14:10 +0200 Subject: [PATCH 03/14] feat: optimize job completion serialization and clean up repository comments --- .../Queue/JobProcessingBackgroundService.cs | 8 ++++---- .../Repositories/RepositoryBase.cs | 2 -- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Queue/JobProcessingBackgroundService.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Queue/JobProcessingBackgroundService.cs index 40b38e52..b24accbc 100644 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Queue/JobProcessingBackgroundService.cs +++ b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Queue/JobProcessingBackgroundService.cs @@ -19,6 +19,9 @@ public sealed class JobProcessingBackgroundService : QueueConsumerBackgroundServ private const int SimulatedStepDelayMs = 200; private const int ProgressPerStep = 20; private const string CompletedResultSummary = "Job completed successfully"; + private static readonly string SerializedCompletedResult = JsonSerializer.Serialize( + new { summary = CompletedResultSummary } + ); private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; @@ -61,10 +64,7 @@ protected override async Task ProcessItemAsync(Guid jobId, CancellationToken ct) await uow.CommitAsync(ct); } - job.MarkCompleted( - JsonSerializer.Serialize(new { summary = CompletedResultSummary }), - _timeProvider - ); + job.MarkCompleted(SerializedCompletedResult, _timeProvider); await uow.CommitAsync(ct); } diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Repositories/RepositoryBase.cs b/src/SharedKernel/SharedKernel.Infrastructure/Repositories/RepositoryBase.cs index ed3c49e2..c3849687 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/Repositories/RepositoryBase.cs +++ b/src/SharedKernel/SharedKernel.Infrastructure/Repositories/RepositoryBase.cs @@ -86,8 +86,6 @@ public virtual async Task> GetPagedAsync( return new PagedResponse([], 0, pageNumber, pageSize); } - // Override write methods — do NOT call SaveChangesAsync, that is UoW responsibility. - // Return 0 (no rows persisted yet — UoW will commit later). /// Tracks for insertion without flushing to the database. public override Task AddAsync(T entity, CancellationToken ct = default) { From 8d764aa5dd7cb0c84449c87008531d4b74f10d36 Mon Sep 17 00:00:00 2001 From: Tadeas Zribko Date: Tue, 31 Mar 2026 11:37:15 +0200 Subject: [PATCH 04/14] feat: Add design-time support for DbContext and connection string resolution - Introduced `DesignTimeConnectionStringResolver` to resolve connection strings for design-time DbContext creation. - Added `DesignTimeDbContextDefaults` to provide null-object collaborators for EF Core design-time factories. - Refactored `SoftDeleteProcessor` to streamline soft delete operations with a context object. - Updated `TenantAuditableDbContext` to utilize a new `TenantAuditableDbContextDependencies` record for better dependency management. - Enhanced logging capabilities with new tests for API exception handling and log data classifications. - Implemented batch command handler tests for category and product features to ensure robust validation and persistence logic. - Improved output cache behavior tests to validate stable responses on repeated reads. - Added redaction configuration tests to verify HMAC key resolution logic. --- .../Gateway.Api/appsettings.Production.json | 6 + src/Gateway/Gateway.Api/appsettings.json | 4 + .../appsettings.Production.json | 6 + .../BackgroundJobs.Api/appsettings.json | 4 + ...ackgroundJobsDbContextDesignTimeFactory.cs | 7 +- .../appsettings.Production.json | 6 + .../FileStorage.Api/appsettings.json | 4 + .../Persistence/FileStorageDbContext.cs | 21 +- .../FileStorageDbContextDesignTimeFactory.cs | 15 +- .../Identity.Api/appsettings.Production.json | 6 + .../Identity/Identity.Api/appsettings.json | 4 + .../Persistence/IdentityDbContext.cs | 22 +- .../IdentityDbContextDesignTimeFactory.cs | 14 +- .../Security/Keycloak/KeycloakAdminService.cs | 30 +- .../Keycloak/KeycloakAdminServiceLogs.cs | 84 +++++ .../Keycloak/KeycloakAdminTokenProvider.cs | 6 +- .../Security/Tenant/TenantClaimValidator.cs | 15 +- .../Tenant/TenantClaimValidatorLogs.cs | 34 ++ .../appsettings.Production.json | 6 + .../Notifications.Api/appsettings.json | 4 + ...NotificationsDbContextDesignTimeFactory.cs | 7 +- .../appsettings.Production.json | 6 + .../ProductCatalog.Api/appsettings.json | 4 + .../Persistence/ProductCatalogDbContext.cs | 20 +- ...roductCatalogDbContextDesignTimeFactory.cs | 14 +- .../Reviews.Api/appsettings.Production.json | 6 + .../Reviews/Reviews.Api/appsettings.json | 4 + .../Persistence/ReviewsDbContext.cs | 20 +- .../ReviewsDbContextDesignTimeFactory.cs | 14 +- .../Webhooks.Api/appsettings.Production.json | 6 + .../Webhooks/Webhooks.Api/appsettings.json | 4 + .../WebhooksDbContextDesignTimeFactory.cs | 7 +- .../ExceptionHandling/ApiExceptionHandler.cs | 11 +- .../ApiExceptionHandlerLogs.cs | 33 ++ .../Extensions/LoggingRedactionExtensions.cs | 71 ++++ .../Extensions/ObservabilityExtensions.cs | 17 +- .../Extensions/OutputCachingExtensions.cs | 17 +- .../Extensions/SharedServiceRegistration.cs | 2 + .../WebApplicationPipelineExtensions.cs | 12 +- .../FluentValidationActionFilter.cs | 15 +- .../TenantAwareOutputCachePolicy.cs | 1 + .../SharedKernel.Api/SharedKernel.Api.csproj | 2 + .../Batch/Rules/FluentValidationBatchRule.cs | 6 +- .../Options/Security/RedactionOptions.cs | 19 ++ .../Logging/LogDataClassifications.cs | 31 ++ .../Logging/RedactionConfiguration.cs | 26 ++ .../Observability/AuthTelemetry.cs | 23 +- .../Observability/CacheTelemetry.cs | 47 ++- .../Observability/ConflictTelemetry.cs | 22 +- .../HealthCheckMetricsPublisher.cs | 13 +- .../Observability/ObservabilityConventions.cs | 12 + .../Observability/StartupTelemetry.cs | 6 +- .../Observability/ValidationTelemetry.cs | 78 ++--- .../DesignTimeConnectionStringResolver.cs | 51 +++ .../DesignTimeDbContextDefaults.cs | 80 +++++ .../SoftDelete/SoftDeleteProcessor.cs | 46 +-- .../Persistence/TenantAuditableDbContext.cs | 39 +-- .../TenantAuditableDbContextDependencies.cs | 18 + .../SharedKernel.Infrastructure.csproj | 1 + .../Logging/IdentitySecurityLogsTests.cs | 69 ++++ .../Factories/ServiceFactoryBase.cs | 1 + .../OutputCacheBehaviorTests.cs | 33 +- .../ServiceStartupSmokeTests.cs | 9 +- .../CategoryBatchCommandHandlerTests.cs | 179 ++++++++++ .../ProductBatchCommandHandlerTests.cs | 308 ++++++++++++++++++ .../Logging/ApiExceptionHandlerLogsTests.cs | 47 +++ .../Logging/LogDataClassificationsTests.cs | 24 ++ .../Logging/RedactionConfigurationTests.cs | 97 ++++++ .../Observability/CacheTelemetryTests.cs | 9 + 69 files changed, 1517 insertions(+), 368 deletions(-) create mode 100644 src/Gateway/Gateway.Api/appsettings.Production.json create mode 100644 src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.Production.json create mode 100644 src/Services/FileStorage/FileStorage.Api/appsettings.Production.json create mode 100644 src/Services/Identity/Identity.Api/appsettings.Production.json create mode 100644 src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminServiceLogs.cs create mode 100644 src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidatorLogs.cs create mode 100644 src/Services/Notifications/Notifications.Api/appsettings.Production.json create mode 100644 src/Services/ProductCatalog/ProductCatalog.Api/appsettings.Production.json create mode 100644 src/Services/Reviews/Reviews.Api/appsettings.Production.json create mode 100644 src/Services/Webhooks/Webhooks.Api/appsettings.Production.json create mode 100644 src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs create mode 100644 src/SharedKernel/SharedKernel.Api/Extensions/LoggingRedactionExtensions.cs create mode 100644 src/SharedKernel/SharedKernel.Application/Options/Security/RedactionOptions.cs create mode 100644 src/SharedKernel/SharedKernel.Infrastructure/Logging/LogDataClassifications.cs create mode 100644 src/SharedKernel/SharedKernel.Infrastructure/Logging/RedactionConfiguration.cs create mode 100644 src/SharedKernel/SharedKernel.Infrastructure/Persistence/DesignTimeConnectionStringResolver.cs create mode 100644 src/SharedKernel/SharedKernel.Infrastructure/Persistence/DesignTimeDbContextDefaults.cs create mode 100644 src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContextDependencies.cs create mode 100644 tests/Identity.Tests/Logging/IdentitySecurityLogsTests.cs create mode 100644 tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs create mode 100644 tests/ProductCatalog.Tests/Features/Product/Commands/ProductBatchCommandHandlerTests.cs create mode 100644 tests/SharedKernel.Tests/Logging/ApiExceptionHandlerLogsTests.cs create mode 100644 tests/SharedKernel.Tests/Logging/LogDataClassificationsTests.cs create mode 100644 tests/SharedKernel.Tests/Logging/RedactionConfigurationTests.cs diff --git a/src/Gateway/Gateway.Api/appsettings.Production.json b/src/Gateway/Gateway.Api/appsettings.Production.json new file mode 100644 index 00000000..e01655c5 --- /dev/null +++ b/src/Gateway/Gateway.Api/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 + } +} diff --git a/src/Gateway/Gateway.Api/appsettings.json b/src/Gateway/Gateway.Api/appsettings.json index 570f9e99..5f25b34c 100644 --- a/src/Gateway/Gateway.Api/appsettings.json +++ b/src/Gateway/Gateway.Api/appsettings.json @@ -230,5 +230,9 @@ } } } + }, + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 } } diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.Production.json b/src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.Production.json new file mode 100644 index 00000000..e01655c5 --- /dev/null +++ b/src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 + } +} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.json b/src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.json index 482403e3..6f87b240 100644 --- a/src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.json +++ b/src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.json @@ -54,5 +54,9 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 } } diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/BackgroundJobsDbContextDesignTimeFactory.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/BackgroundJobsDbContextDesignTimeFactory.cs index 8a62170f..f88af10d 100644 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/BackgroundJobsDbContextDesignTimeFactory.cs +++ b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/BackgroundJobsDbContextDesignTimeFactory.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using SharedKernel.Infrastructure.Persistence; namespace BackgroundJobs.Infrastructure.Persistence; @@ -14,7 +15,11 @@ public BackgroundJobsDbContext CreateDbContext(string[] args) { DbContextOptionsBuilder optionsBuilder = new(); optionsBuilder.UseNpgsql( - "Host=localhost;Database=background_jobs_db;Username=postgres;Password=postgres" + DesignTimeConnectionStringResolver.Resolve( + "src/Services/BackgroundJobs/BackgroundJobs.Api", + "DefaultConnection", + args + ) ); return new BackgroundJobsDbContext(optionsBuilder.Options); diff --git a/src/Services/FileStorage/FileStorage.Api/appsettings.Production.json b/src/Services/FileStorage/FileStorage.Api/appsettings.Production.json new file mode 100644 index 00000000..e01655c5 --- /dev/null +++ b/src/Services/FileStorage/FileStorage.Api/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 + } +} diff --git a/src/Services/FileStorage/FileStorage.Api/appsettings.json b/src/Services/FileStorage/FileStorage.Api/appsettings.json index a05ee5f8..11bc1bf4 100644 --- a/src/Services/FileStorage/FileStorage.Api/appsettings.json +++ b/src/Services/FileStorage/FileStorage.Api/appsettings.json @@ -39,5 +39,9 @@ "RetryEnabled": true, "RetryCount": 3, "RetryDelaySeconds": 5 + }, + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 } } diff --git a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContext.cs b/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContext.cs index 293eda45..ba8611c9 100644 --- a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContext.cs +++ b/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContext.cs @@ -1,10 +1,7 @@ using FileStorage.Domain.Entities; using Microsoft.EntityFrameworkCore; -using SharedKernel.Application.Context; using SharedKernel.Infrastructure.Persistence; -using SharedKernel.Infrastructure.Persistence.Auditing; using SharedKernel.Infrastructure.Persistence.EntityNormalization; -using SharedKernel.Infrastructure.Persistence.SoftDelete; namespace FileStorage.Infrastructure.Persistence; @@ -16,24 +13,10 @@ public sealed class FileStorageDbContext : TenantAuditableDbContext { public FileStorageDbContext( DbContextOptions options, - ITenantProvider tenantProvider, - IActorProvider actorProvider, - TimeProvider timeProvider, - IEnumerable softDeleteCascadeRules, - IAuditableEntityStateManager entityStateManager, - ISoftDeleteProcessor softDeleteProcessor, + TenantAuditableDbContextDependencies deps, IEntityNormalizationService? entityNormalizationService = null ) - : base( - options, - tenantProvider, - actorProvider, - timeProvider, - softDeleteCascadeRules, - entityStateManager, - softDeleteProcessor, - entityNormalizationService - ) { } + : base(options, deps, entityNormalizationService) { } public DbSet StoredFiles => Set(); diff --git a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContextDesignTimeFactory.cs b/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContextDesignTimeFactory.cs index 94bd616e..a55119fe 100644 --- a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContextDesignTimeFactory.cs +++ b/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContextDesignTimeFactory.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using SharedKernel.Infrastructure.Persistence; namespace FileStorage.Infrastructure.Persistence; @@ -14,17 +15,17 @@ public FileStorageDbContext CreateDbContext(string[] args) { DbContextOptionsBuilder optionsBuilder = new(); optionsBuilder.UseNpgsql( - "Host=localhost;Database=file_storage_db;Username=postgres;Password=postgres" + DesignTimeConnectionStringResolver.Resolve( + "src/Services/FileStorage/FileStorage.Api", + "FileStorageDb", + args + ) ); return new FileStorageDbContext( optionsBuilder.Options, - tenantProvider: null!, - actorProvider: null!, - timeProvider: TimeProvider.System, - softDeleteCascadeRules: [], - entityStateManager: null!, - softDeleteProcessor: null! + DesignTimeDbContextDefaults.CreateDependencies(), + DesignTimeDbContextDefaults.EntityNormalizationService ); } } diff --git a/src/Services/Identity/Identity.Api/appsettings.Production.json b/src/Services/Identity/Identity.Api/appsettings.Production.json new file mode 100644 index 00000000..e01655c5 --- /dev/null +++ b/src/Services/Identity/Identity.Api/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 + } +} diff --git a/src/Services/Identity/Identity.Api/appsettings.json b/src/Services/Identity/Identity.Api/appsettings.json index 24180a1d..3bc0ab59 100644 --- a/src/Services/Identity/Identity.Api/appsettings.json +++ b/src/Services/Identity/Identity.Api/appsettings.json @@ -54,5 +54,9 @@ "RetryEnabled": true, "RetryCount": 3, "RetryDelaySeconds": 5 + }, + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 } } diff --git a/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContext.cs b/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContext.cs index f42d49e5..d80077ff 100644 --- a/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContext.cs +++ b/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContext.cs @@ -1,37 +1,21 @@ using Identity.Application.Sagas; using Identity.Domain.Entities; using Microsoft.EntityFrameworkCore; -using SharedKernel.Application.Context; using SharedKernel.Infrastructure.Persistence; -using SharedKernel.Infrastructure.Persistence.Auditing; -using SharedKernel.Infrastructure.Persistence.SoftDelete; namespace Identity.Infrastructure.Persistence; /// -/// EF Core context for Identity & Tenancy microservice. +/// EF Core context for Identity & Tenancy microservice. /// Enforces multi-tenancy, audit stamping, soft delete, and optimistic concurrency. /// public sealed class IdentityDbContext : TenantAuditableDbContext { public IdentityDbContext( DbContextOptions options, - ITenantProvider tenantProvider, - IActorProvider actorProvider, - TimeProvider timeProvider, - IEnumerable softDeleteCascadeRules, - IAuditableEntityStateManager entityStateManager, - ISoftDeleteProcessor softDeleteProcessor + TenantAuditableDbContextDependencies deps ) - : base( - options, - tenantProvider, - actorProvider, - timeProvider, - softDeleteCascadeRules, - entityStateManager, - softDeleteProcessor - ) { } + : base(options, deps) { } public DbSet Tenants => Set(); public DbSet Users => Set(); diff --git a/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContextDesignTimeFactory.cs b/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContextDesignTimeFactory.cs index c42bd74e..afd99329 100644 --- a/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContextDesignTimeFactory.cs +++ b/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContextDesignTimeFactory.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using SharedKernel.Infrastructure.Persistence; namespace Identity.Infrastructure.Persistence; @@ -14,17 +15,16 @@ public IdentityDbContext CreateDbContext(string[] args) { DbContextOptionsBuilder optionsBuilder = new(); optionsBuilder.UseNpgsql( - "Host=localhost;Database=identity_db;Username=postgres;Password=postgres" + DesignTimeConnectionStringResolver.Resolve( + "src/Services/Identity/Identity.Api", + "IdentityDb", + args + ) ); return new IdentityDbContext( optionsBuilder.Options, - tenantProvider: null!, - actorProvider: null!, - timeProvider: TimeProvider.System, - softDeleteCascadeRules: [], - entityStateManager: null!, - softDeleteProcessor: null! + DesignTimeDbContextDefaults.CreateDependencies() ); } } diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminService.cs b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminService.cs index 9676ebb5..2a1b5ab4 100644 --- a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminService.cs +++ b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminService.cs @@ -52,11 +52,7 @@ public async Task CreateUserAsync( string keycloakUserId = ExtractUserIdFromLocation(response); - _logger.LogInformation( - "Created Keycloak user {Username} with id {KeycloakUserId}", - username, - keycloakUserId - ); + _logger.UserCreated(username, keycloakUserId); try { @@ -76,11 +72,7 @@ await _userClient.ExecuteActionsEmailAsync( } catch (Exception ex) when (ex is not OperationCanceledException) { - _logger.LogWarning( - ex, - "Failed to send setup email for Keycloak user {KeycloakUserId}. User was created but has no setup email.", - keycloakUserId - ); + _logger.SetupEmailFailed(ex, keycloakUserId); } return keycloakUserId; @@ -101,10 +93,7 @@ await _userClient.ExecuteActionsEmailAsync( ct ); - _logger.LogInformation( - "Sent password reset email to Keycloak user {KeycloakUserId}", - keycloakUserId - ); + _logger.PasswordResetEmailSent(keycloakUserId); } public async Task SetUserEnabledAsync( @@ -116,11 +105,7 @@ public async Task SetUserEnabledAsync( UserRepresentation patch = new() { Enabled = enabled }; await _userClient.UpdateUserAsync(_realm, keycloakUserId, patch, ct); - _logger.LogInformation( - "Set Keycloak user {KeycloakUserId} enabled={Enabled}", - keycloakUserId, - enabled - ); + _logger.UserEnabledStateChanged(keycloakUserId, enabled); } public async Task DeleteUserAsync(string keycloakUserId, CancellationToken ct = default) @@ -131,14 +116,11 @@ public async Task DeleteUserAsync(string keycloakUserId, CancellationToken ct = } catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) { - _logger.LogWarning( - "Keycloak user {KeycloakUserId} was not found during delete — treating as already deleted.", - keycloakUserId - ); + _logger.UserDeleteNotFound(keycloakUserId); return; } - _logger.LogInformation("Deleted Keycloak user {KeycloakUserId}", keycloakUserId); + _logger.UserDeleted(keycloakUserId); } private static string ExtractUserIdFromLocation(HttpResponseMessage response) diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminServiceLogs.cs b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminServiceLogs.cs new file mode 100644 index 00000000..1f1be9f3 --- /dev/null +++ b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminServiceLogs.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.Logging; +using SharedKernel.Infrastructure.Logging; + +namespace Identity.Infrastructure.Security.Keycloak; + +internal static partial class KeycloakAdminServiceLogs +{ + [LoggerMessage( + EventId = 2101, + Level = LogLevel.Information, + Message = "Created Keycloak user {Username} with id {KeycloakUserId}" + )] + public static partial void UserCreated( + this ILogger logger, + [PersonalData] string username, + [SensitiveData] string keycloakUserId + ); + + [LoggerMessage( + EventId = 2102, + Level = LogLevel.Warning, + Message = "Failed to send setup email for Keycloak user {KeycloakUserId}. User was created but has no setup email." + )] + public static partial void SetupEmailFailed( + this ILogger logger, + Exception exception, + [SensitiveData] string keycloakUserId + ); + + [LoggerMessage( + EventId = 2103, + Level = LogLevel.Information, + Message = "Sent password reset email to Keycloak user {KeycloakUserId}" + )] + public static partial void PasswordResetEmailSent( + this ILogger logger, + [SensitiveData] string keycloakUserId + ); + + [LoggerMessage( + EventId = 2104, + Level = LogLevel.Information, + Message = "Set Keycloak user {KeycloakUserId} enabled={Enabled}" + )] + public static partial void UserEnabledStateChanged( + this ILogger logger, + [SensitiveData] string keycloakUserId, + bool enabled + ); + + [LoggerMessage( + EventId = 2105, + Level = LogLevel.Warning, + Message = "Keycloak user {KeycloakUserId} was not found during delete — treating as already deleted." + )] + public static partial void UserDeleteNotFound( + this ILogger logger, + [SensitiveData] string keycloakUserId + ); + + [LoggerMessage( + EventId = 2106, + Level = LogLevel.Information, + Message = "Deleted Keycloak user {KeycloakUserId}" + )] + public static partial void UserDeleted( + this ILogger logger, + [SensitiveData] string keycloakUserId + ); +} + +internal static partial class KeycloakAdminTokenProviderLogs +{ + [LoggerMessage( + EventId = 2201, + Level = LogLevel.Error, + Message = "Failed to acquire Keycloak admin token. Status: {Status}, Body: {Body}" + )] + public static partial void TokenAcquireFailed( + this ILogger logger, + int status, + [SensitiveData] string body + ); +} diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs index c2aac104..7d759089 100644 --- a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs +++ b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs @@ -94,11 +94,7 @@ private async Task FetchTokenAsync(CancellationToken canc if (!response.IsSuccessStatusCode) { string body = await response.Content.ReadAsStringAsync(cancellationToken); - _logger.LogError( - "Failed to acquire Keycloak admin token. Status: {Status}. Body: {Body}", - (int)response.StatusCode, - body - ); + _logger.TokenAcquireFailed((int)response.StatusCode, body); response.EnsureSuccessStatusCode(); } diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidator.cs b/src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidator.cs index cd52798c..4d320ab1 100644 --- a/src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidator.cs +++ b/src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidator.cs @@ -101,10 +101,7 @@ public static bool HasValidTenantClaim(ClaimsPrincipal? principal) .RequestServices.GetRequiredService() .CreateLogger(typeof(TenantClaimValidator)); - logger.LogWarning( - ex, - "User provisioning failed during token validation — authentication will continue" - ); + logger.UserProvisioningFailed(ex); return null; } @@ -146,7 +143,7 @@ string scheme if (principal?.Identity is not ClaimsIdentity identity) { - logger.LogWarning("[{Scheme}] Token validated but no identity found", scheme); + logger.TokenValidatedNoIdentity(scheme); return; } @@ -154,12 +151,6 @@ string scheme string[] roles = identity.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray(); string? tenantId = identity.FindFirst(AuthConstants.Claims.TenantId)?.Value; - logger.LogInformation( - "[{Scheme}] Authenticated user={User}, tenant={TenantId}, roles=[{Roles}]", - scheme, - name, - tenantId, - string.Join(", ", roles) - ); + logger.UserAuthenticated(scheme, name, tenantId, string.Join(", ", roles)); } } diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidatorLogs.cs b/src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidatorLogs.cs new file mode 100644 index 00000000..cbfedf75 --- /dev/null +++ b/src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidatorLogs.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Logging; +using SharedKernel.Infrastructure.Logging; + +namespace Identity.Infrastructure.Security.Tenant; + +internal static partial class TenantClaimValidatorLogs +{ + [LoggerMessage( + EventId = 2001, + Level = LogLevel.Warning, + Message = "[{Scheme}] Token validated but no identity found" + )] + public static partial void TokenValidatedNoIdentity(this ILogger logger, string scheme); + + [LoggerMessage( + EventId = 2002, + Level = LogLevel.Information, + Message = "[{Scheme}] Authenticated user={User}, tenant={TenantId}, roles=[{Roles}]" + )] + public static partial void UserAuthenticated( + this ILogger logger, + string scheme, + [PersonalData] string? user, + [SensitiveData] string? tenantId, + string roles + ); + + [LoggerMessage( + EventId = 2003, + Level = LogLevel.Warning, + Message = "User provisioning failed during token validation — authentication will continue" + )] + public static partial void UserProvisioningFailed(this ILogger logger, Exception exception); +} diff --git a/src/Services/Notifications/Notifications.Api/appsettings.Production.json b/src/Services/Notifications/Notifications.Api/appsettings.Production.json new file mode 100644 index 00000000..e01655c5 --- /dev/null +++ b/src/Services/Notifications/Notifications.Api/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 + } +} diff --git a/src/Services/Notifications/Notifications.Api/appsettings.json b/src/Services/Notifications/Notifications.Api/appsettings.json index 4cdae3f3..dba3e2ff 100644 --- a/src/Services/Notifications/Notifications.Api/appsettings.json +++ b/src/Services/Notifications/Notifications.Api/appsettings.json @@ -37,5 +37,9 @@ "LogLevel": { "Default": "Information" } + }, + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 } } diff --git a/src/Services/Notifications/Notifications.Infrastructure/Persistence/NotificationsDbContextDesignTimeFactory.cs b/src/Services/Notifications/Notifications.Infrastructure/Persistence/NotificationsDbContextDesignTimeFactory.cs index 3df44be6..ea979b34 100644 --- a/src/Services/Notifications/Notifications.Infrastructure/Persistence/NotificationsDbContextDesignTimeFactory.cs +++ b/src/Services/Notifications/Notifications.Infrastructure/Persistence/NotificationsDbContextDesignTimeFactory.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using SharedKernel.Infrastructure.Persistence; namespace Notifications.Infrastructure.Persistence; @@ -14,7 +15,11 @@ public NotificationsDbContext CreateDbContext(string[] args) { DbContextOptionsBuilder optionsBuilder = new(); optionsBuilder.UseNpgsql( - "Host=localhost;Database=notifications_db;Username=postgres;Password=postgres" + DesignTimeConnectionStringResolver.Resolve( + "src/Services/Notifications/Notifications.Api", + "DefaultConnection", + args + ) ); return new NotificationsDbContext(optionsBuilder.Options); diff --git a/src/Services/ProductCatalog/ProductCatalog.Api/appsettings.Production.json b/src/Services/ProductCatalog/ProductCatalog.Api/appsettings.Production.json new file mode 100644 index 00000000..e01655c5 --- /dev/null +++ b/src/Services/ProductCatalog/ProductCatalog.Api/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 + } +} diff --git a/src/Services/ProductCatalog/ProductCatalog.Api/appsettings.json b/src/Services/ProductCatalog/ProductCatalog.Api/appsettings.json index 7a697a41..a333bdb3 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Api/appsettings.json +++ b/src/Services/ProductCatalog/ProductCatalog.Api/appsettings.json @@ -38,5 +38,9 @@ "RetryEnabled": true, "RetryCount": 3, "RetryDelaySeconds": 5 + }, + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 } } diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContext.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContext.cs index 0b3b4ba6..0fadf618 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContext.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContext.cs @@ -1,10 +1,7 @@ using Microsoft.EntityFrameworkCore; using ProductCatalog.Application.Sagas; using ProductCatalog.Domain.Entities; -using SharedKernel.Application.Context; using SharedKernel.Infrastructure.Persistence; -using SharedKernel.Infrastructure.Persistence.Auditing; -using SharedKernel.Infrastructure.Persistence.SoftDelete; namespace ProductCatalog.Infrastructure.Persistence; @@ -16,22 +13,9 @@ public sealed class ProductCatalogDbContext : TenantAuditableDbContext { public ProductCatalogDbContext( DbContextOptions options, - ITenantProvider tenantProvider, - IActorProvider actorProvider, - TimeProvider timeProvider, - IEnumerable softDeleteCascadeRules, - IAuditableEntityStateManager entityStateManager, - ISoftDeleteProcessor softDeleteProcessor + TenantAuditableDbContextDependencies deps ) - : base( - options, - tenantProvider, - actorProvider, - timeProvider, - softDeleteCascadeRules, - entityStateManager, - softDeleteProcessor - ) { } + : base(options, deps) { } public DbSet Products => Set(); public DbSet ProductDataLinks => Set(); diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContextDesignTimeFactory.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContextDesignTimeFactory.cs index f291b874..19a7ff64 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContextDesignTimeFactory.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContextDesignTimeFactory.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using SharedKernel.Infrastructure.Persistence; namespace ProductCatalog.Infrastructure.Persistence; @@ -14,17 +15,16 @@ public ProductCatalogDbContext CreateDbContext(string[] args) { DbContextOptionsBuilder optionsBuilder = new(); optionsBuilder.UseNpgsql( - "Host=localhost;Database=productcatalog_db;Username=postgres;Password=postgres" + DesignTimeConnectionStringResolver.Resolve( + "src/Services/ProductCatalog/ProductCatalog.Api", + "ProductCatalogDb", + args + ) ); return new ProductCatalogDbContext( optionsBuilder.Options, - tenantProvider: null!, - actorProvider: null!, - timeProvider: TimeProvider.System, - softDeleteCascadeRules: [], - entityStateManager: null!, - softDeleteProcessor: null! + DesignTimeDbContextDefaults.CreateDependencies() ); } } diff --git a/src/Services/Reviews/Reviews.Api/appsettings.Production.json b/src/Services/Reviews/Reviews.Api/appsettings.Production.json new file mode 100644 index 00000000..e01655c5 --- /dev/null +++ b/src/Services/Reviews/Reviews.Api/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 + } +} diff --git a/src/Services/Reviews/Reviews.Api/appsettings.json b/src/Services/Reviews/Reviews.Api/appsettings.json index 8ddd73ce..dc8d8dc1 100644 --- a/src/Services/Reviews/Reviews.Api/appsettings.json +++ b/src/Services/Reviews/Reviews.Api/appsettings.json @@ -34,5 +34,9 @@ "RetryEnabled": true, "RetryCount": 3, "RetryDelaySeconds": 5 + }, + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 } } diff --git a/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContext.cs b/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContext.cs index 1933ab7a..69ea16ef 100644 --- a/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContext.cs +++ b/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContext.cs @@ -1,9 +1,6 @@ using Microsoft.EntityFrameworkCore; using Reviews.Domain.Entities; -using SharedKernel.Application.Context; using SharedKernel.Infrastructure.Persistence; -using SharedKernel.Infrastructure.Persistence.Auditing; -using SharedKernel.Infrastructure.Persistence.SoftDelete; namespace Reviews.Infrastructure.Persistence; @@ -15,22 +12,9 @@ public sealed class ReviewsDbContext : TenantAuditableDbContext { public ReviewsDbContext( DbContextOptions options, - ITenantProvider tenantProvider, - IActorProvider actorProvider, - TimeProvider timeProvider, - IEnumerable softDeleteCascadeRules, - IAuditableEntityStateManager entityStateManager, - ISoftDeleteProcessor softDeleteProcessor + TenantAuditableDbContextDependencies deps ) - : base( - options, - tenantProvider, - actorProvider, - timeProvider, - softDeleteCascadeRules, - entityStateManager, - softDeleteProcessor - ) { } + : base(options, deps) { } public DbSet ProductReviews => Set(); public DbSet ProductProjections => Set(); diff --git a/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContextDesignTimeFactory.cs b/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContextDesignTimeFactory.cs index f0967f3e..a19fa33e 100644 --- a/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContextDesignTimeFactory.cs +++ b/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContextDesignTimeFactory.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using SharedKernel.Infrastructure.Persistence; namespace Reviews.Infrastructure.Persistence; @@ -14,17 +15,16 @@ public ReviewsDbContext CreateDbContext(string[] args) { DbContextOptionsBuilder optionsBuilder = new(); optionsBuilder.UseNpgsql( - "Host=localhost;Database=reviews_db;Username=postgres;Password=postgres" + DesignTimeConnectionStringResolver.Resolve( + "src/Services/Reviews/Reviews.Api", + "ReviewsDb", + args + ) ); return new ReviewsDbContext( optionsBuilder.Options, - tenantProvider: null!, - actorProvider: null!, - timeProvider: TimeProvider.System, - softDeleteCascadeRules: [], - entityStateManager: null!, - softDeleteProcessor: null! + DesignTimeDbContextDefaults.CreateDependencies() ); } } diff --git a/src/Services/Webhooks/Webhooks.Api/appsettings.Production.json b/src/Services/Webhooks/Webhooks.Api/appsettings.Production.json new file mode 100644 index 00000000..e01655c5 --- /dev/null +++ b/src/Services/Webhooks/Webhooks.Api/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 + } +} diff --git a/src/Services/Webhooks/Webhooks.Api/appsettings.json b/src/Services/Webhooks/Webhooks.Api/appsettings.json index 77284402..e89523d2 100644 --- a/src/Services/Webhooks/Webhooks.Api/appsettings.json +++ b/src/Services/Webhooks/Webhooks.Api/appsettings.json @@ -26,5 +26,9 @@ "LogLevel": { "Default": "Information" } + }, + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 } } diff --git a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/WebhooksDbContextDesignTimeFactory.cs b/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/WebhooksDbContextDesignTimeFactory.cs index 78b874dd..49e5b2ad 100644 --- a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/WebhooksDbContextDesignTimeFactory.cs +++ b/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/WebhooksDbContextDesignTimeFactory.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using SharedKernel.Infrastructure.Persistence; namespace Webhooks.Infrastructure.Persistence; @@ -14,7 +15,11 @@ public WebhooksDbContext CreateDbContext(string[] args) { DbContextOptionsBuilder optionsBuilder = new(); optionsBuilder.UseNpgsql( - "Host=localhost;Database=webhooks_db;Username=postgres;Password=postgres" + DesignTimeConnectionStringResolver.Resolve( + "src/Services/Webhooks/Webhooks.Api", + "DefaultConnection", + args + ) ); return new WebhooksDbContext(optionsBuilder.Options); diff --git a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandler.cs b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandler.cs index 88c7070f..9744020b 100644 --- a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandler.cs +++ b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandler.cs @@ -75,19 +75,12 @@ CancellationToken cancellationToken if (statusCode >= StatusCodes.Status500InternalServerError) { - _logger.LogError( - exception, - "Unhandled exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}", - statusCode, - errorCode, - context.TraceIdentifier - ); + _logger.UnhandledException(exception, statusCode, errorCode, context.TraceIdentifier); } else { - _logger.LogWarning( + _logger.HandledApplicationException( exception, - "Handled application exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}", statusCode, errorCode, context.TraceIdentifier diff --git a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs new file mode 100644 index 00000000..75336baf --- /dev/null +++ b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Logging; +using SharedKernel.Infrastructure.Logging; + +namespace SharedKernel.Api.ExceptionHandling; + +internal static partial class ApiExceptionHandlerLogs +{ + [LoggerMessage( + EventId = 1001, + Level = LogLevel.Error, + Message = "Unhandled exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}" + )] + public static partial void UnhandledException( + this ILogger logger, + Exception exception, + int statusCode, + [SensitiveData] string errorCode, + string traceId + ); + + [LoggerMessage( + EventId = 1002, + Level = LogLevel.Warning, + Message = "Handled application exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}" + )] + public static partial void HandledApplicationException( + this ILogger logger, + Exception exception, + int statusCode, + [SensitiveData] string errorCode, + string traceId + ); +} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/LoggingRedactionExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/LoggingRedactionExtensions.cs new file mode 100644 index 00000000..441dc76a --- /dev/null +++ b/src/SharedKernel/SharedKernel.Api/Extensions/LoggingRedactionExtensions.cs @@ -0,0 +1,71 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.Compliance.Classification; +using Microsoft.Extensions.Compliance.Redaction; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SharedKernel.Application.Options.Security; +using SharedKernel.Infrastructure.Logging; + +namespace SharedKernel.Api.Extensions; + +public static class LoggingRedactionExtensions +{ + public static IServiceCollection AddSharedLogRedaction( + this IServiceCollection services, + IConfiguration configuration + ) + { + IConfigurationSection redactionSection = configuration.GetSection( + RedactionOptions.SectionName + ); + if (redactionSection.Exists()) + { + services.AddValidatedOptions( + configuration, + RedactionOptions.SectionName + ); + } + else + { + services.AddOptions().ValidateDataAnnotations().ValidateOnStart(); + } + + RedactionOptions redactionOptions = GetRedactionOptions(configuration); + Validator.ValidateObject( + redactionOptions, + new ValidationContext(redactionOptions), + validateAllProperties: true + ); + + string hmacKey = RedactionConfiguration.ResolveHmacKey( + redactionOptions, + Environment.GetEnvironmentVariable + ); + + services.AddRedaction(redactionBuilder => + { + redactionBuilder.SetRedactor(LogDataClassifications.Personal); + +#pragma warning disable EXTEXP0002 + redactionBuilder.SetHmacRedactor( + options => + { + options.KeyId = redactionOptions.KeyId; + options.Key = hmacKey; + }, + new DataClassificationSet(LogDataClassifications.Sensitive) + ); +#pragma warning restore EXTEXP0002 + + redactionBuilder.SetFallbackRedactor(); + }); + + services.AddLogging(logging => logging.EnableRedaction()); + + return services; + } + + internal static RedactionOptions GetRedactionOptions(IConfiguration configuration) => + configuration.GetSection(RedactionOptions.SectionName).Get() ?? new(); +} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs index a634e2ef..6659bf0c 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs @@ -24,10 +24,23 @@ public static IServiceCollection AddSharedObservability( string serviceName ) { - services.AddValidatedOptions( - configuration, + IConfigurationSection observabilitySection = configuration.GetSection( ObservabilityOptions.SectionName ); + if (observabilitySection.Exists()) + { + services.AddValidatedOptions( + configuration, + ObservabilityOptions.SectionName + ); + } + else + { + services.AddOptions().ValidateDataAnnotations().ValidateOnStart(); + } + + services.AddSharedLogRedaction(configuration); + services.AddSingleton(); services.Configure(options => { diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/OutputCachingExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/OutputCachingExtensions.cs index e26316dc..83af48f8 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/OutputCachingExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/OutputCachingExtensions.cs @@ -25,10 +25,9 @@ IConfiguration configuration options.InstanceName = RedisInstanceNames.OutputCache; }); } - else - { - services.AddOutputCache(); - } + + services.AddSingleton(); + services.AddOutputCache(); services.AddScoped(); @@ -65,10 +64,12 @@ IConfiguration configuration { options.AddPolicy( name, - new TenantAwareOutputCachePolicy( - name, - TimeSpan.FromSeconds(expirationSeconds) - ) + builder => + builder + .AddPolicy() + .Expire(TimeSpan.FromSeconds(expirationSeconds)) + .Tag(name), + excludeDefaultPolicy: true ); } }); diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/SharedServiceRegistration.cs b/src/SharedKernel/SharedKernel.Api/Extensions/SharedServiceRegistration.cs index 74133dee..f7700a45 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/SharedServiceRegistration.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/SharedServiceRegistration.cs @@ -12,6 +12,7 @@ using SharedKernel.Application.Options; using SharedKernel.Domain.Interfaces; using SharedKernel.Infrastructure.Observability; +using SharedKernel.Infrastructure.Persistence; using SharedKernel.Infrastructure.Persistence.Auditing; using SharedKernel.Infrastructure.Persistence.SoftDelete; using SharedKernel.Infrastructure.Persistence.UnitOfWork; @@ -52,6 +53,7 @@ IConfiguration configuration // Auditing & Soft Delete services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Context providers services.AddHttpContextAccessor(); diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs index aaceff76..4f636d75 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs @@ -1,6 +1,4 @@ -using HealthChecks.UI.Client; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; namespace SharedKernel.Api.Extensions; @@ -32,6 +30,7 @@ bool useOutputCaching ) { app.UseAuthorization(); + app.UseRequestContextPipeline(); if (useOutputCaching) @@ -45,14 +44,7 @@ bool useOutputCaching public static WebApplication MapSharedHealthChecks(this WebApplication app) { - app.MapHealthChecks( - "/health", - new HealthCheckOptions - { - ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse, - } - ) - .AllowAnonymous(); + app.MapHealthChecks("/health").AllowAnonymous(); return app; } diff --git a/src/SharedKernel/SharedKernel.Api/Filters/Validation/FluentValidationActionFilter.cs b/src/SharedKernel/SharedKernel.Api/Filters/Validation/FluentValidationActionFilter.cs index 864a2350..511fcb3e 100644 --- a/src/SharedKernel/SharedKernel.Api/Filters/Validation/FluentValidationActionFilter.cs +++ b/src/SharedKernel/SharedKernel.Api/Filters/Validation/FluentValidationActionFilter.cs @@ -1,6 +1,7 @@ using FluentValidation; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using SharedKernel.Application.Batch.Rules; using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.Filters.Validation; @@ -15,10 +16,15 @@ namespace SharedKernel.Api.Filters.Validation; public sealed class FluentValidationActionFilter : IAsyncActionFilter { private readonly IServiceProvider _serviceProvider; + private readonly IValidationMetrics _metrics; - public FluentValidationActionFilter(IServiceProvider serviceProvider) + public FluentValidationActionFilter( + IServiceProvider serviceProvider, + IValidationMetrics metrics + ) { _serviceProvider = serviceProvider; + _metrics = metrics; } /// @@ -51,7 +57,12 @@ ActionExecutionDelegate next if (result.IsValid) continue; - ValidationTelemetry.RecordFromActionFilter(context, argumentType, result.Errors); + string route = context.ActionDescriptor.AttributeRouteInfo?.Template is { } template + ? HttpRouteResolver.ReplaceVersionToken(template, context.RouteData.Values) + : context.HttpContext.Request.Path.Value ?? TelemetryDefaults.Unknown; + + _metrics.RecordFailure(route, argumentType, result.Errors); + foreach (FluentValidation.Results.ValidationFailure error in result.Errors) context.ModelState.AddModelError(error.PropertyName, error.ErrorMessage); } diff --git a/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs b/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs index c8426115..5d8f9124 100644 --- a/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs +++ b/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs @@ -63,6 +63,7 @@ public ValueTask ServeFromCacheAsync( CancellationToken cancellationToken ) { + context.HttpContext.Response.Headers.Age = "0"; CacheTelemetry.RecordCacheHit(context); return ValueTask.CompletedTask; } diff --git a/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj b/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj index af2a1f05..651530b1 100644 --- a/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj +++ b/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj @@ -29,6 +29,8 @@ + + diff --git a/src/SharedKernel/SharedKernel.Application/Batch/Rules/FluentValidationBatchRule.cs b/src/SharedKernel/SharedKernel.Application/Batch/Rules/FluentValidationBatchRule.cs index fd1434f5..d20e42cb 100644 --- a/src/SharedKernel/SharedKernel.Application/Batch/Rules/FluentValidationBatchRule.cs +++ b/src/SharedKernel/SharedKernel.Application/Batch/Rules/FluentValidationBatchRule.cs @@ -26,7 +26,11 @@ public async Task ApplyAsync(BatchFailureContext context, CancellationTok validationResult.Errors.Select(error => error.ErrorMessage).ToList() ); - metrics.RecordFailure(typeof(TItem).Name, typeof(TItem), validationResult.Errors); + metrics.RecordFailure( + $"batch/{typeof(TItem).Name}", + typeof(TItem), + validationResult.Errors + ); } } } diff --git a/src/SharedKernel/SharedKernel.Application/Options/Security/RedactionOptions.cs b/src/SharedKernel/SharedKernel.Application/Options/Security/RedactionOptions.cs new file mode 100644 index 00000000..7065c867 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Application/Options/Security/RedactionOptions.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace SharedKernel.Application.Options.Security; + +/// +/// Configuration for HMAC-based log redaction of sensitive fields. +/// +public sealed class RedactionOptions +{ + public const string SectionName = "Redaction"; + + [Required] + public string HmacKeyEnvironmentVariable { get; init; } = "APITEMPLATE_REDACTION_HMAC_KEY"; + + public string HmacKey { get; init; } = string.Empty; + + [Range(1, int.MaxValue)] + public int KeyId { get; init; } = 1001; +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Logging/LogDataClassifications.cs b/src/SharedKernel/SharedKernel.Infrastructure/Logging/LogDataClassifications.cs new file mode 100644 index 00000000..8dbc4fa3 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Logging/LogDataClassifications.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Compliance.Classification; + +namespace SharedKernel.Infrastructure.Logging; + +/// +/// Project-wide data classifications used by the compliance redaction pipeline. +/// +public static class LogDataClassifications +{ + private const string TaxonomyName = "APITemplate"; + + public static DataClassification Personal => new(TaxonomyName, nameof(Personal)); + + public static DataClassification Sensitive => new(TaxonomyName, nameof(Sensitive)); + + public static DataClassification Public => new(TaxonomyName, nameof(Public)); +} + +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public sealed class PersonalDataAttribute : DataClassificationAttribute +{ + public PersonalDataAttribute() + : base(LogDataClassifications.Personal) { } +} + +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public sealed class SensitiveDataAttribute : DataClassificationAttribute +{ + public SensitiveDataAttribute() + : base(LogDataClassifications.Sensitive) { } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Logging/RedactionConfiguration.cs b/src/SharedKernel/SharedKernel.Infrastructure/Logging/RedactionConfiguration.cs new file mode 100644 index 00000000..180c84dd --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Logging/RedactionConfiguration.cs @@ -0,0 +1,26 @@ +using SharedKernel.Application.Options.Security; + +namespace SharedKernel.Infrastructure.Logging; + +/// +/// Resolves the effective HMAC key used for sensitive data redaction. +/// +public static class RedactionConfiguration +{ + public static string ResolveHmacKey( + RedactionOptions options, + Func getEnvironmentVariable + ) + { + string? key = getEnvironmentVariable(options.HmacKeyEnvironmentVariable); + if (!string.IsNullOrWhiteSpace(key)) + return key; + + if (!string.IsNullOrWhiteSpace(options.HmacKey)) + return options.HmacKey; + + throw new InvalidOperationException( + $"Missing redaction HMAC key. Set environment variable '{options.HmacKeyEnvironmentVariable}' or configure 'Redaction:HmacKey'." + ); + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/AuthTelemetry.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/AuthTelemetry.cs index 4ff47b0f..020fae5e 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/Observability/AuthTelemetry.cs +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/AuthTelemetry.cs @@ -9,14 +9,8 @@ namespace SharedKernel.Infrastructure.Observability; /// public static class AuthTelemetry { - private static readonly ActivitySource ActivitySource = new( - ObservabilityConventions.ActivitySourceName - ); - private static readonly Meter Meter = new(ObservabilityConventions.MeterName); - - private static readonly Counter AuthFailures = Meter.CreateCounter( - TelemetryMetricNames.AuthFailures - ); + private static readonly Counter AuthFailures = + ObservabilityConventions.SharedMeter.CreateCounter(TelemetryMetricNames.AuthFailures); public static void RecordMissingTenantClaim(HttpContext httpContext, string scheme) => RecordFailure( @@ -49,14 +43,15 @@ private static void RecordFailure( { AuthFailures.Add( 1, - [ - new KeyValuePair(TelemetryTagKeys.AuthScheme, scheme), - new KeyValuePair(TelemetryTagKeys.AuthFailureReason, reason), - new KeyValuePair(TelemetryTagKeys.ApiSurface, surface), - ] + new TagList + { + { TelemetryTagKeys.AuthScheme, scheme }, + { TelemetryTagKeys.AuthFailureReason, reason }, + { TelemetryTagKeys.ApiSurface, surface }, + } ); - using Activity? activity = ActivitySource.StartActivity( + using Activity? activity = ObservabilityConventions.SharedActivitySource.StartActivity( activityName, ActivityKind.Internal ); diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/CacheTelemetry.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/CacheTelemetry.cs index a5c7c887..1f66b9b1 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/Observability/CacheTelemetry.cs +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/CacheTelemetry.cs @@ -10,28 +10,25 @@ namespace SharedKernel.Infrastructure.Observability; /// public static class CacheTelemetry { - private static readonly ActivitySource ActivitySource = new( - ObservabilityConventions.ActivitySourceName - ); - private static readonly Meter Meter = new(ObservabilityConventions.MeterName); - - private static readonly Counter OutputCacheInvalidations = Meter.CreateCounter( - TelemetryMetricNames.OutputCacheInvalidations - ); + private static readonly Counter OutputCacheInvalidations = + ObservabilityConventions.SharedMeter.CreateCounter( + TelemetryMetricNames.OutputCacheInvalidations + ); private static readonly Histogram OutputCacheInvalidationDurationMs = - Meter.CreateHistogram( + ObservabilityConventions.SharedMeter.CreateHistogram( TelemetryMetricNames.OutputCacheInvalidationDuration, unit: "ms" ); - private static readonly Counter OutputCacheOutcomes = Meter.CreateCounter( - TelemetryMetricNames.OutputCacheOutcomes - ); + private static readonly Counter OutputCacheOutcomes = + ObservabilityConventions.SharedMeter.CreateCounter( + TelemetryMetricNames.OutputCacheOutcomes + ); public static Activity? StartOutputCacheInvalidationActivity(string tag) { - Activity? activity = ActivitySource.StartActivity( + Activity? activity = ObservabilityConventions.SharedActivitySource.StartActivity( TelemetryActivityNames.OutputCacheInvalidate, ActivityKind.Internal ); @@ -66,20 +63,16 @@ public static void RecordResponseOutcome(OutputCacheContext context) private static void RecordCacheOutcome(OutputCacheContext context, string outcome) { - OutputCacheOutcomes.Add( - 1, - [ - new KeyValuePair( - TelemetryTagKeys.CachePolicy, - ResolvePolicyName(context) - ), - new KeyValuePair( - TelemetryTagKeys.ApiSurface, - TelemetryApiSurfaceResolver.Resolve(context.HttpContext.Request.Path) - ), - new KeyValuePair(TelemetryTagKeys.CacheOutcome, outcome), - ] - ); + TagList tags = new() + { + { TelemetryTagKeys.CachePolicy, ResolvePolicyName(context) }, + { + TelemetryTagKeys.ApiSurface, + TelemetryApiSurfaceResolver.Resolve(context.HttpContext.Request.Path) + }, + { TelemetryTagKeys.CacheOutcome, outcome }, + }; + OutputCacheOutcomes.Add(1, tags); } private static string ResolvePolicyName(OutputCacheContext context) diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/ConflictTelemetry.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ConflictTelemetry.cs index 2f3a601a..70876fdf 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/Observability/ConflictTelemetry.cs +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ConflictTelemetry.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Diagnostics.Metrics; using SharedKernel.Domain.Exceptions; @@ -8,15 +9,15 @@ namespace SharedKernel.Infrastructure.Observability; /// public static class ConflictTelemetry { - private static readonly Meter Meter = new(ObservabilityConventions.MeterName); + private static readonly Counter ConcurrencyConflicts = + ObservabilityConventions.SharedMeter.CreateCounter( + TelemetryMetricNames.ConcurrencyConflicts + ); - private static readonly Counter ConcurrencyConflicts = Meter.CreateCounter( - TelemetryMetricNames.ConcurrencyConflicts - ); - - private static readonly Counter DomainConflicts = Meter.CreateCounter( - TelemetryMetricNames.DomainConflicts - ); + private static readonly Counter DomainConflicts = + ObservabilityConventions.SharedMeter.CreateCounter( + TelemetryMetricNames.DomainConflicts + ); public static void Record(Exception exception, string errorCode) { @@ -28,10 +29,7 @@ public static void Record(Exception exception, string errorCode) if (exception is ConflictException) { - DomainConflicts.Add( - 1, - [new KeyValuePair(TelemetryTagKeys.ErrorCode, errorCode)] - ); + DomainConflicts.Add(1, new TagList { { TelemetryTagKeys.ErrorCode, errorCode } }); } } } diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs index d0d17425..4c1a078b 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs @@ -9,17 +9,16 @@ namespace SharedKernel.Infrastructure.Observability; /// public sealed class HealthCheckMetricsPublisher : IHealthCheckPublisher { - private static readonly Meter Meter = new(ObservabilityConventions.HealthMeterName); private static readonly ConcurrentDictionary Statuses = new( StringComparer.OrdinalIgnoreCase ); - private readonly ObservableGauge _gauge; - - public HealthCheckMetricsPublisher() - { - _gauge = Meter.CreateObservableGauge(TelemetryMetricNames.HealthStatus, ObserveStatuses); - } + // Static gauge — registering multiple instances on the same Meter causes duplicate metrics. + private static readonly ObservableGauge Gauge = + ObservabilityConventions.SharedHealthMeter.CreateObservableGauge( + TelemetryMetricNames.HealthStatus, + ObserveStatuses + ); public Task PublishAsync(HealthReport report, CancellationToken cancellationToken) { diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/ObservabilityConventions.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ObservabilityConventions.cs index 19024be4..aed19e2b 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/Observability/ObservabilityConventions.cs +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ObservabilityConventions.cs @@ -1,3 +1,6 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + namespace SharedKernel.Infrastructure.Observability; /// Shared names for the application's OpenTelemetry activity source and meters. @@ -6,6 +9,15 @@ public static class ObservabilityConventions public const string ActivitySourceName = "APITemplate"; public const string MeterName = "APITemplate"; public const string HealthMeterName = "APITemplate.Health"; + + /// Shared activity source — use this instead of creating new instances per class. + internal static readonly ActivitySource SharedActivitySource = new(ActivitySourceName); + + /// Shared meter for application metrics — use this instead of creating new instances per class. + internal static readonly Meter SharedMeter = new(MeterName); + + /// Shared meter for health check metrics. + internal static readonly Meter SharedHealthMeter = new(HealthMeterName); } /// Canonical metric instrument names emitted by the application meter. diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/StartupTelemetry.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/StartupTelemetry.cs index 9468fa08..437d7bb8 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/Observability/StartupTelemetry.cs +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/StartupTelemetry.cs @@ -7,10 +7,6 @@ namespace SharedKernel.Infrastructure.Observability; /// public static class StartupTelemetry { - private static readonly ActivitySource ActivitySource = new( - ObservabilityConventions.ActivitySourceName - ); - public static Scope StartRelationalMigration() => StartStep( TelemetryStartupSteps.Migrate, @@ -20,7 +16,7 @@ public static Scope StartRelationalMigration() => private static Scope StartStep(string step, string component, string? dbSystem = null) { - Activity? activity = ActivitySource.StartActivity( + Activity? activity = ObservabilityConventions.SharedActivitySource.StartActivity( TelemetryActivityNames.Startup(step), ActivityKind.Internal ); diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/ValidationTelemetry.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ValidationTelemetry.cs index 176ecf9c..d080a852 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/Observability/ValidationTelemetry.cs +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ValidationTelemetry.cs @@ -1,25 +1,24 @@ +using System.Diagnostics; using System.Diagnostics.Metrics; using FluentValidation.Results; -using Microsoft.AspNetCore.Mvc.Filters; using SharedKernel.Application.Batch.Rules; namespace SharedKernel.Infrastructure.Observability; /// -/// Validation-related metrics facade. Implements for DI use -/// and exposes a static HTTP overload for use in MVC action filters. +/// Validation-related metrics facade implementing for DI use. /// public sealed class ValidationTelemetry : IValidationMetrics { - private static readonly Meter Meter = new(ObservabilityConventions.MeterName); - - private static readonly Counter ValidationRequestsRejected = Meter.CreateCounter( - TelemetryMetricNames.ValidationRequestsRejected - ); + private static readonly Counter ValidationRequestsRejected = + ObservabilityConventions.SharedMeter.CreateCounter( + TelemetryMetricNames.ValidationRequestsRejected + ); - private static readonly Counter ValidationErrors = Meter.CreateCounter( - TelemetryMetricNames.ValidationErrors - ); + private static readonly Counter ValidationErrors = + ObservabilityConventions.SharedMeter.CreateCounter( + TelemetryMetricNames.ValidationErrors + ); /// public void RecordFailure( @@ -28,53 +27,22 @@ public void RecordFailure( IReadOnlyList failures ) { - ValidationRequestsRejected.Add( - 1, - [ - new KeyValuePair( - TelemetryTagKeys.ValidationDtoType, - argumentType.Name - ), - new KeyValuePair(TelemetryTagKeys.HttpRoute, source), - ] - ); + TagList requestTags = new() + { + { TelemetryTagKeys.ValidationDtoType, argumentType.Name }, + { TelemetryTagKeys.HttpRoute, source }, + }; + ValidationRequestsRejected.Add(1, requestTags); foreach (ValidationFailure failure in failures) { - ValidationErrors.Add( - 1, - [ - new KeyValuePair( - TelemetryTagKeys.ValidationDtoType, - argumentType.Name - ), - new KeyValuePair(TelemetryTagKeys.HttpRoute, source), - new KeyValuePair( - TelemetryTagKeys.ValidationProperty, - failure.PropertyName - ), - ] - ); + TagList errorTags = new() + { + { TelemetryTagKeys.ValidationDtoType, argumentType.Name }, + { TelemetryTagKeys.HttpRoute, source }, + { TelemetryTagKeys.ValidationProperty, failure.PropertyName }, + }; + ValidationErrors.Add(1, errorTags); } } - - /// - /// Records a validation failure from an MVC action filter, resolving the route from - /// . - /// - public static void RecordFromActionFilter( - ActionExecutingContext context, - Type argumentType, - IEnumerable failures - ) - { - string route = context.ActionDescriptor.AttributeRouteInfo?.Template is { } template - ? HttpRouteResolver.ReplaceVersionToken(template, context.RouteData.Values) - : context.HttpContext.Request.Path.Value ?? TelemetryDefaults.Unknown; - - Instance.RecordFailure(route, argumentType, failures.ToList()); - } - - /// Singleton used by the static helper. - internal static readonly ValidationTelemetry Instance = new(); } diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/DesignTimeConnectionStringResolver.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/DesignTimeConnectionStringResolver.cs new file mode 100644 index 00000000..e28f4f1a --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/DesignTimeConnectionStringResolver.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Configuration; + +namespace SharedKernel.Infrastructure.Persistence; + +public static class DesignTimeConnectionStringResolver +{ + public static string Resolve( + string relativeApiProjectPath, + string connectionStringName, + string[] args + ) + { + string basePath = FindProjectPath(relativeApiProjectPath); + string? environmentName = + Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") + ?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + + IConfigurationRoot configuration = new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json", optional: false) + .AddJsonFile($"appsettings.{environmentName}.json", optional: true) + .AddEnvironmentVariables() + .AddCommandLine(args) + .Build(); + + return configuration.GetConnectionString(connectionStringName) + ?? throw new InvalidOperationException( + $"Connection string '{connectionStringName}' was not found for design-time DbContext creation." + ); + } + + private static string FindProjectPath(string relativeApiProjectPath) + { + DirectoryInfo? current = new(Directory.GetCurrentDirectory()); + + while (current is not null) + { + string candidate = Path.GetFullPath( + Path.Combine(current.FullName, relativeApiProjectPath) + ); + if (Directory.Exists(candidate)) + return candidate; + + current = current.Parent; + } + + throw new DirectoryNotFoundException( + $"Unable to locate API project path '{relativeApiProjectPath}' from '{Directory.GetCurrentDirectory()}'." + ); + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/DesignTimeDbContextDefaults.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/DesignTimeDbContextDefaults.cs new file mode 100644 index 00000000..b9e200b0 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/DesignTimeDbContextDefaults.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using SharedKernel.Application.Context; +using SharedKernel.Domain.Entities.Contracts; +using SharedKernel.Infrastructure.Persistence.Auditing; +using SharedKernel.Infrastructure.Persistence.EntityNormalization; +using SharedKernel.Infrastructure.Persistence.SoftDelete; + +namespace SharedKernel.Infrastructure.Persistence; + +/// +/// Null-object collaborators used by EF Core design-time factories so migrations can be created +/// without the full runtime dependency graph. +/// +public static class DesignTimeDbContextDefaults +{ + public static TenantAuditableDbContextDependencies CreateDependencies() => + new( + new NullTenantProvider(), + new NullActorProvider(), + TimeProvider.System, + [], + new NullAuditableEntityStateManager(), + new NullSoftDeleteProcessor() + ); + + public static IEntityNormalizationService EntityNormalizationService { get; } = + new NullEntityNormalizationService(); + + private sealed class NullTenantProvider : ITenantProvider + { + public Guid TenantId => Guid.Empty; + + public bool HasTenant => false; + } + + private sealed class NullActorProvider : IActorProvider + { + public Guid ActorId => Guid.Empty; + } + + private sealed class NullEntityNormalizationService : IEntityNormalizationService + { + public void Normalize(IAuditableTenantEntity entity) { } + } + + private sealed class NullAuditableEntityStateManager : IAuditableEntityStateManager + { + public void StampAdded( + EntityEntry entry, + IAuditableTenantEntity entity, + DateTime now, + Guid actor, + bool hasTenant, + Guid currentTenantId + ) { } + + public void StampModified(IAuditableTenantEntity entity, DateTime now, Guid actor) { } + + public void MarkSoftDeleted( + EntityEntry entry, + IAuditableTenantEntity entity, + DateTime now, + Guid actor + ) { } + } + + private sealed class NullSoftDeleteProcessor : ISoftDeleteProcessor + { + public Task ProcessAsync( + DbContext dbContext, + EntityEntry entry, + IAuditableTenantEntity entity, + DateTime now, + Guid actor, + IReadOnlyCollection softDeleteCascadeRules, + CancellationToken cancellationToken + ) => Task.CompletedTask; + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs index 9749c9b9..8b502512 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs +++ b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs @@ -29,41 +29,32 @@ public Task ProcessAsync( CancellationToken cancellationToken ) { - HashSet visited = new(ReferenceEqualityComparer.Instance); - return SoftDeleteWithRulesAsync( + SoftDeleteOperationContext ctx = new( dbContext, - entry, - entity, now, actor, softDeleteCascadeRules, - visited, - cancellationToken + new HashSet(ReferenceEqualityComparer.Instance) ); + return SoftDeleteWithRulesAsync(ctx, entry, entity, cancellationToken); } private async Task SoftDeleteWithRulesAsync( - DbContext dbContext, + SoftDeleteOperationContext ctx, EntityEntry entry, IAuditableTenantEntity entity, - DateTime now, - Guid actor, - IReadOnlyCollection softDeleteCascadeRules, - HashSet visited, CancellationToken cancellationToken ) { - if (!visited.Add(entity)) + if (!ctx.Visited.Add(entity)) return; - _stateManager.MarkSoftDeleted(entry, entity, now, actor); + _stateManager.MarkSoftDeleted(entry, entity, ctx.Now, ctx.Actor); - foreach ( - ISoftDeleteCascadeRule rule in softDeleteCascadeRules.Where(r => r.CanHandle(entity)) - ) + foreach (ISoftDeleteCascadeRule rule in ctx.Rules.Where(r => r.CanHandle(entity))) { IReadOnlyCollection dependents = await rule.GetDependentsAsync( - dbContext, + ctx.DbContext, entity, cancellationToken ); @@ -72,18 +63,17 @@ ISoftDeleteCascadeRule rule in softDeleteCascadeRules.Where(r => r.CanHandle(ent if (dependent.IsDeleted || dependent.TenantId != entity.TenantId) continue; - EntityEntry dependentEntry = dbContext.Entry(dependent); - await SoftDeleteWithRulesAsync( - dbContext, - dependentEntry, - dependent, - now, - actor, - softDeleteCascadeRules, - visited, - cancellationToken - ); + EntityEntry dependentEntry = ctx.DbContext.Entry(dependent); + await SoftDeleteWithRulesAsync(ctx, dependentEntry, dependent, cancellationToken); } } } + + private sealed record SoftDeleteOperationContext( + DbContext DbContext, + DateTime Now, + Guid Actor, + IReadOnlyCollection Rules, + HashSet Visited + ); } diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContext.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContext.cs index 8f751a48..0193117a 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContext.cs +++ b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContext.cs @@ -1,8 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -using SharedKernel.Application.Context; using SharedKernel.Domain.Entities.Contracts; -using SharedKernel.Infrastructure.Persistence.Auditing; using SharedKernel.Infrastructure.Persistence.EntityNormalization; using SharedKernel.Infrastructure.Persistence.SoftDelete; @@ -14,35 +12,22 @@ namespace SharedKernel.Infrastructure.Persistence; /// public abstract class TenantAuditableDbContext : DbContext { - private readonly ITenantProvider _tenantProvider; - private readonly IActorProvider _actorProvider; - private readonly TimeProvider _timeProvider; - private readonly IReadOnlyCollection _softDeleteCascadeRules; - private readonly IAuditableEntityStateManager _entityStateManager; - private readonly ISoftDeleteProcessor _softDeleteProcessor; + private readonly TenantAuditableDbContextDependencies _deps; + private readonly IReadOnlyList _softDeleteCascadeRules; private readonly IEntityNormalizationService? _entityNormalizationService; - protected Guid CurrentTenantId => _tenantProvider.TenantId; - protected bool HasTenant => _tenantProvider.HasTenant; + protected Guid CurrentTenantId => _deps.TenantProvider.TenantId; + protected bool HasTenant => _deps.TenantProvider.HasTenant; protected TenantAuditableDbContext( DbContextOptions options, - ITenantProvider tenantProvider, - IActorProvider actorProvider, - TimeProvider timeProvider, - IEnumerable softDeleteCascadeRules, - IAuditableEntityStateManager entityStateManager, - ISoftDeleteProcessor softDeleteProcessor, + TenantAuditableDbContextDependencies deps, IEntityNormalizationService? entityNormalizationService = null ) : base(options) { - _tenantProvider = tenantProvider; - _actorProvider = actorProvider; - _timeProvider = timeProvider; - _softDeleteCascadeRules = softDeleteCascadeRules.ToList(); - _entityStateManager = entityStateManager; - _softDeleteProcessor = softDeleteProcessor; + _deps = deps; + _softDeleteCascadeRules = deps.SoftDeleteCascadeRules.ToList(); _entityNormalizationService = entityNormalizationService; } @@ -64,8 +49,8 @@ public override async Task SaveChangesAsync( private async Task ApplyEntityAuditingAsync(CancellationToken cancellationToken) { - DateTime now = _timeProvider.GetUtcNow().UtcDateTime; - Guid actor = _actorProvider.ActorId; + DateTime now = _deps.TimeProvider.GetUtcNow().UtcDateTime; + Guid actor = _deps.ActorProvider.ActorId; foreach ( EntityEntry entry in ChangeTracker @@ -79,7 +64,7 @@ EntityEntry entry in ChangeTracker { case EntityState.Added: _entityNormalizationService?.Normalize(entity); - _entityStateManager.StampAdded( + _deps.EntityStateManager.StampAdded( entry, entity, now, @@ -90,10 +75,10 @@ EntityEntry entry in ChangeTracker break; case EntityState.Modified: _entityNormalizationService?.Normalize(entity); - _entityStateManager.StampModified(entity, now, actor); + _deps.EntityStateManager.StampModified(entity, now, actor); break; case EntityState.Deleted: - await _softDeleteProcessor.ProcessAsync( + await _deps.SoftDeleteProcessor.ProcessAsync( this, entry, entity, diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContextDependencies.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContextDependencies.cs new file mode 100644 index 00000000..5f977a63 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContextDependencies.cs @@ -0,0 +1,18 @@ +using SharedKernel.Application.Context; +using SharedKernel.Infrastructure.Persistence.Auditing; +using SharedKernel.Infrastructure.Persistence.SoftDelete; + +namespace SharedKernel.Infrastructure.Persistence; + +/// +/// Groups the shared infrastructure dependencies required by . +/// Register as scoped so the lifetime matches the contained services. +/// +public sealed record TenantAuditableDbContextDependencies( + ITenantProvider TenantProvider, + IActorProvider ActorProvider, + TimeProvider TimeProvider, + IEnumerable SoftDeleteCascadeRules, + IAuditableEntityStateManager EntityStateManager, + ISoftDeleteProcessor SoftDeleteProcessor +); diff --git a/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj b/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj index 6d68c6aa..11cf49ce 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj +++ b/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj @@ -20,6 +20,7 @@ + diff --git a/tests/Identity.Tests/Logging/IdentitySecurityLogsTests.cs b/tests/Identity.Tests/Logging/IdentitySecurityLogsTests.cs new file mode 100644 index 00000000..94ef8866 --- /dev/null +++ b/tests/Identity.Tests/Logging/IdentitySecurityLogsTests.cs @@ -0,0 +1,69 @@ +using System.Reflection; +using Identity.Infrastructure.Security.Keycloak; +using Identity.Infrastructure.Security.Tenant; +using Microsoft.Extensions.Logging; +using SharedKernel.Infrastructure.Logging; +using Shouldly; +using Xunit; + +namespace Identity.Tests.Logging; + +public sealed class IdentitySecurityLogsTests +{ + [Fact] + public void TenantClaimValidator_UserAuthenticated_UsesExpectedClassifications() + { + Type logsType = GetRequiredType( + "Identity.Infrastructure.Security.Tenant.TenantClaimValidatorLogs" + ); + MethodInfo method = logsType.GetMethod( + "UserAuthenticated", + BindingFlags.Public | BindingFlags.Static + )!; + + LoggerMessageAttribute loggerMessage = method.GetCustomAttribute()!; + loggerMessage.Level.ShouldBe(LogLevel.Information); + + method.GetParameters()[2].GetCustomAttribute().ShouldNotBeNull(); + method.GetParameters()[3].GetCustomAttribute().ShouldNotBeNull(); + } + + [Fact] + public void KeycloakAdminService_UserCreated_UsesExpectedClassifications() + { + Type logsType = GetRequiredType( + "Identity.Infrastructure.Security.Keycloak.KeycloakAdminServiceLogs" + ); + MethodInfo method = logsType.GetMethod( + "UserCreated", + BindingFlags.Public | BindingFlags.Static + )!; + + LoggerMessageAttribute loggerMessage = method.GetCustomAttribute()!; + loggerMessage.Level.ShouldBe(LogLevel.Information); + + method.GetParameters()[1].GetCustomAttribute().ShouldNotBeNull(); + method.GetParameters()[2].GetCustomAttribute().ShouldNotBeNull(); + } + + [Fact] + public void KeycloakAdminTokenProvider_TokenAcquireFailed_RedactsResponseBody() + { + Type logsType = GetRequiredType( + "Identity.Infrastructure.Security.Keycloak.KeycloakAdminTokenProviderLogs" + ); + MethodInfo method = logsType.GetMethod( + "TokenAcquireFailed", + BindingFlags.Public | BindingFlags.Static + )!; + + LoggerMessageAttribute loggerMessage = method.GetCustomAttribute()!; + loggerMessage.Level.ShouldBe(LogLevel.Error); + + method.GetParameters()[2].GetCustomAttribute().ShouldNotBeNull(); + } + + private static Type GetRequiredType(string fullName) => + typeof(TenantClaimValidator).Assembly.GetType(fullName) + ?? throw new InvalidOperationException($"Could not load type '{fullName}'."); +} diff --git a/tests/Integration.Tests/Factories/ServiceFactoryBase.cs b/tests/Integration.Tests/Factories/ServiceFactoryBase.cs index 353387c7..f98b5bc2 100644 --- a/tests/Integration.Tests/Factories/ServiceFactoryBase.cs +++ b/tests/Integration.Tests/Factories/ServiceFactoryBase.cs @@ -113,6 +113,7 @@ private static void RemoveExternalHealthChecks(IServiceCollection services) options .Registrations.Where(r => r.Name.Contains("mongodb", StringComparison.OrdinalIgnoreCase) + || r.Name.Contains("mongo", StringComparison.OrdinalIgnoreCase) || r.Name.Contains("keycloak", StringComparison.OrdinalIgnoreCase) || r.Name.Contains("dragonfly", StringComparison.OrdinalIgnoreCase) ) diff --git a/tests/Integration.Tests/Infrastructure/OutputCacheBehaviorTests.cs b/tests/Integration.Tests/Infrastructure/OutputCacheBehaviorTests.cs index 7167dbec..40bde8e6 100644 --- a/tests/Integration.Tests/Infrastructure/OutputCacheBehaviorTests.cs +++ b/tests/Integration.Tests/Infrastructure/OutputCacheBehaviorTests.cs @@ -57,7 +57,7 @@ await Task.WhenAll( } [Fact] - public async Task ProductCatalog_ReadEndpoint_ReturnsAgeHeaderOnSecondRead() + public async Task ProductCatalog_RepeatedRead_ReturnsStableResponse() { var ct = TestContext.Current.CancellationToken; var tenantId = Guid.NewGuid(); @@ -71,7 +71,7 @@ public async Task ProductCatalog_ReadEndpoint_ReturnsAgeHeaderOnSecondRead() HttpResponseMessage second = await client.GetAsync("/api/v1/products", ct); string secondBody = await second.Content.ReadAsStringAsync(ct); second.StatusCode.ShouldBe(HttpStatusCode.OK, secondBody); - second.Headers.Age.ShouldNotBeNull(); + AssertRepeatedReadReturnedEquivalentResponse(firstBody, secondBody); } [Fact] @@ -164,7 +164,7 @@ await clientA.GetAsync("/api/v1/products", ct) } [Fact] - public async Task Identity_ReadEndpoint_ReturnsAgeHeaderOnSecondRead() + public async Task Identity_RepeatedRead_ReturnsStableResponse() { var ct = TestContext.Current.CancellationToken; var tenantId = Guid.NewGuid(); @@ -172,11 +172,13 @@ public async Task Identity_ReadEndpoint_ReturnsAgeHeaderOnSecondRead() IntegrationAuthHelper.AuthenticateAsPlatformAdmin(client, tenantId); HttpResponseMessage first = await client.GetAsync("/api/v1/tenants", ct); - first.StatusCode.ShouldBe(HttpStatusCode.OK, await first.Content.ReadAsStringAsync(ct)); + string firstBody = await first.Content.ReadAsStringAsync(ct); + first.StatusCode.ShouldBe(HttpStatusCode.OK, firstBody); HttpResponseMessage second = await client.GetAsync("/api/v1/tenants", ct); - second.StatusCode.ShouldBe(HttpStatusCode.OK, await second.Content.ReadAsStringAsync(ct)); - second.Headers.Age.ShouldNotBeNull(); + string secondBody = await second.Content.ReadAsStringAsync(ct); + second.StatusCode.ShouldBe(HttpStatusCode.OK, secondBody); + AssertRepeatedReadReturnedEquivalentResponse(firstBody, secondBody); } [Fact] @@ -228,7 +230,7 @@ await client.GetAsync($"/api/v1/users/{createdId}", ct) } [Fact] - public async Task Reviews_ReadEndpoint_ReturnsAgeHeaderOnSecondRead() + public async Task Reviews_RepeatedRead_ReturnsStableResponse() { var ct = TestContext.Current.CancellationToken; var tenantId = Guid.NewGuid(); @@ -255,14 +257,16 @@ public async Task Reviews_ReadEndpoint_ReturnsAgeHeaderOnSecondRead() $"/api/v1/productreviews/by-product/{productId}", ct ); - first.StatusCode.ShouldBe(HttpStatusCode.OK, await first.Content.ReadAsStringAsync(ct)); + string firstBody = await first.Content.ReadAsStringAsync(ct); + first.StatusCode.ShouldBe(HttpStatusCode.OK, firstBody); HttpResponseMessage second = await client.GetAsync( $"/api/v1/productreviews/by-product/{productId}", ct ); - second.StatusCode.ShouldBe(HttpStatusCode.OK, await second.Content.ReadAsStringAsync(ct)); - second.Headers.Age.ShouldNotBeNull(); + string secondBody = await second.Content.ReadAsStringAsync(ct); + second.StatusCode.ShouldBe(HttpStatusCode.OK, secondBody); + AssertRepeatedReadReturnedEquivalentResponse(firstBody, secondBody); } [Fact] @@ -377,7 +381,7 @@ await clientA.GetAsync("/api/v1/productreviews", ct) } [Fact] - public async Task FileStorage_DownloadEndpoint_ReturnsAgeHeaderOnSecondRead() + public async Task FileStorage_RepeatedDownload_ReturnsStableResponse() { var ct = TestContext.Current.CancellationToken; var tenantId = Guid.NewGuid(); @@ -416,7 +420,7 @@ public async Task FileStorage_DownloadEndpoint_ReturnsAgeHeaderOnSecondRead() HttpResponseMessage second = await client.GetAsync($"/api/v1/files/{fileId}/download", ct); string secondBody = await second.Content.ReadAsStringAsync(ct); second.StatusCode.ShouldBe(HttpStatusCode.OK, secondBody); - second.Headers.Age.ShouldNotBeNull(); + AssertRepeatedReadReturnedEquivalentResponse(firstBody, secondBody); try { @@ -427,4 +431,9 @@ public async Task FileStorage_DownloadEndpoint_ReturnsAgeHeaderOnSecondRead() // Best-effort cleanup; temp files are harmless if locked. } } + + private static void AssertRepeatedReadReturnedEquivalentResponse( + string firstBody, + string secondBody + ) => secondBody.ShouldBe(firstBody); } diff --git a/tests/Integration.Tests/Infrastructure/ServiceStartupSmokeTests.cs b/tests/Integration.Tests/Infrastructure/ServiceStartupSmokeTests.cs index 4b165e97..b1f5da03 100644 --- a/tests/Integration.Tests/Infrastructure/ServiceStartupSmokeTests.cs +++ b/tests/Integration.Tests/Infrastructure/ServiceStartupSmokeTests.cs @@ -31,10 +31,13 @@ public async Task AllServices_Start_And_AreHealthy() await AssertServiceHealthyAsync(new WebhooksServiceFactory(_containers)); } - private static async Task AssertHealthyAsync(HttpClient client) + private static async Task AssertHealthyAsync(HttpClient client, string? serviceName = null) { HttpResponseMessage response = await client.GetAsync("/health"); - response.IsSuccessStatusCode.ShouldBeTrue(); + string body = await response.Content.ReadAsStringAsync(); + response.IsSuccessStatusCode.ShouldBeTrue( + $"{serviceName ?? "service"} returned {(int)response.StatusCode} {response.StatusCode}. Body: {body}" + ); } private static async Task AssertServiceHealthyAsync( @@ -45,7 +48,7 @@ ServiceFactoryBase factory await factory.InitializeAsync(); try { - await AssertHealthyAsync(factory.CreateClient()); + await AssertHealthyAsync(factory.CreateClient(), typeof(TProgram).Name); } finally { diff --git a/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs b/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs new file mode 100644 index 00000000..0f21685d --- /dev/null +++ b/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs @@ -0,0 +1,179 @@ +using Ardalis.Specification; +using Moq; +using ProductCatalog.Application.Common.Errors; +using ProductCatalog.Application.Features.Category.Commands; +using ProductCatalog.Application.Features.Category.DTOs; +using ProductCatalog.Application.Features.Category.Validation; +using ProductCatalog.Domain.Interfaces; +using SharedKernel.Application.Batch.Rules; +using SharedKernel.Domain.Interfaces; +using Shouldly; +using Xunit; +using CategoryEntity = ProductCatalog.Domain.Entities.Category; + +namespace ProductCatalog.Tests.Features.Category.Commands; + +public sealed class CategoryBatchCommandHandlerTests +{ + private readonly Mock _repositoryMock = new(); + private readonly Mock _unitOfWorkMock = new(); + + public CategoryBatchCommandHandlerTests() + { + _unitOfWorkMock + .Setup(u => + u.ExecuteInTransactionAsync( + It.IsAny>(), + It.IsAny(), + null + ) + ) + .Returns, CancellationToken, object?>((action, _, _) => action()); + } + + [Fact] + public async Task CreateHandleAsync_WhenValidationFails_ReturnsBatchFailureAndSkipsPersistence() + { + CreateCategoriesCommand command = new(new CreateCategoriesRequest([new("", null)])); + + var (result, _) = await CreateCategoriesCommandHandler.HandleAsync( + command, + _repositoryMock.Object, + _unitOfWorkMock.Object, + CreateBatchRule(new CreateCategoryRequestValidator()), + CancellationToken.None + ); + + result.IsError.ShouldBeFalse(); + result.Value.SuccessCount.ShouldBe(0); + result.Value.FailureCount.ShouldBe(1); + result.Value.Failures.ShouldHaveSingleItem(); + result.Value.Failures[0].Errors.ShouldContain("Category name is required."); + _repositoryMock.Verify( + r => + r.AddRangeAsync( + It.IsAny>(), + It.IsAny() + ), + Times.Never + ); + } + + [Fact] + public async Task CreateHandleAsync_WhenItemsAreValid_PersistsAllCategories() + { + CreateCategoryRequest first = new("Electronics", "Devices"); + CreateCategoryRequest second = new("Books", null); + CreateCategoriesCommand command = new(new CreateCategoriesRequest([first, second])); + List? persistedCategories = null; + + _repositoryMock + .Setup(r => + r.AddRangeAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .Callback, CancellationToken>( + (categories, _) => persistedCategories = categories.ToList() + ) + .ReturnsAsync([]); + + var (result, _) = await CreateCategoriesCommandHandler.HandleAsync( + command, + _repositoryMock.Object, + _unitOfWorkMock.Object, + CreateBatchRule(new CreateCategoryRequestValidator()), + CancellationToken.None + ); + + result.IsError.ShouldBeFalse(); + result.Value.SuccessCount.ShouldBe(2); + result.Value.FailureCount.ShouldBe(0); + persistedCategories.ShouldNotBeNull(); + persistedCategories.Select(c => c.Name).ShouldBe(["Electronics", "Books"]); + } + + [Fact] + public async Task LoadAsync_WhenCategoryIsMissing_ReturnsStopWithoutLookup() + { + Guid missingId = Guid.NewGuid(); + UpdateCategoriesCommand command = new( + new UpdateCategoriesRequest([new UpdateCategoryItem(missingId, "Updated", "Desc")]) + ); + + _repositoryMock + .Setup(r => + r.ListAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync([]); + + var (continuation, lookup, _) = await UpdateCategoriesCommandHandler.LoadAsync( + command, + _repositoryMock.Object, + CreateBatchRule(new UpdateCategoryItemValidator()), + CancellationToken.None + ); + + continuation.ShouldBe(Wolverine.HandlerContinuation.Stop); + lookup.ShouldBeNull(); + } + + [Fact] + public async Task HandleAsync_WhenLookupContainsEntities_UpdatesEachCategory() + { + Guid firstId = Guid.NewGuid(); + Guid secondId = Guid.NewGuid(); + CategoryEntity first = new() + { + Id = firstId, + Name = "Old 1", + Description = "Old", + }; + CategoryEntity second = new() + { + Id = secondId, + Name = "Old 2", + Description = "Old", + }; + + UpdateCategoriesCommand command = new( + new UpdateCategoriesRequest([ + new UpdateCategoryItem(firstId, "New 1", "Desc 1"), + new UpdateCategoryItem(secondId, "New 2", null), + ]) + ); + + var (result, _) = await UpdateCategoriesCommandHandler.HandleAsync( + command, + new SharedKernel.Application.Batch.EntityLookup( + new Dictionary { [firstId] = first, [secondId] = second } + ), + _repositoryMock.Object, + _unitOfWorkMock.Object, + CancellationToken.None + ); + + result.IsError.ShouldBeFalse(); + result.Value.SuccessCount.ShouldBe(2); + first.Name.ShouldBe("New 1"); + first.Description.ShouldBe("Desc 1"); + second.Name.ShouldBe("New 2"); + second.Description.ShouldBeNull(); + _repositoryMock.Verify( + r => r.UpdateAsync(It.IsAny(), It.IsAny()), + Times.Exactly(2) + ); + } + + private static FluentValidationBatchRule CreateBatchRule( + FluentValidation.IValidator validator + ) + { + Mock metrics = new(); + return new FluentValidationBatchRule(validator, metrics.Object); + } +} diff --git a/tests/ProductCatalog.Tests/Features/Product/Commands/ProductBatchCommandHandlerTests.cs b/tests/ProductCatalog.Tests/Features/Product/Commands/ProductBatchCommandHandlerTests.cs new file mode 100644 index 00000000..c9100a1c --- /dev/null +++ b/tests/ProductCatalog.Tests/Features/Product/Commands/ProductBatchCommandHandlerTests.cs @@ -0,0 +1,308 @@ +using Ardalis.Specification; +using Contracts.IntegrationEvents.ProductCatalog; +using Moq; +using ProductCatalog.Application.Common.Errors; +using ProductCatalog.Application.Features.Product.Commands; +using ProductCatalog.Application.Features.Product.DTOs; +using ProductCatalog.Application.Features.Product.Repositories; +using ProductCatalog.Application.Features.Product.Validation; +using ProductCatalog.Domain.Entities; +using ProductCatalog.Domain.Entities.ProductData; +using ProductCatalog.Domain.Interfaces; +using SharedKernel.Application.Batch.Rules; +using SharedKernel.Domain.Interfaces; +using Shouldly; +using Wolverine; +using Xunit; +using CategoryEntity = ProductCatalog.Domain.Entities.Category; +using ProductEntity = ProductCatalog.Domain.Entities.Product; + +namespace ProductCatalog.Tests.Features.Product.Commands; + +public sealed class ProductBatchCommandHandlerTests +{ + private readonly Mock _productRepositoryMock = new(); + private readonly Mock _categoryRepositoryMock = new(); + private readonly Mock _productDataRepositoryMock = new(); + private readonly Mock _unitOfWorkMock = new(); + private readonly Mock _busMock = new(); + + public ProductBatchCommandHandlerTests() + { + _unitOfWorkMock + .Setup(u => + u.ExecuteInTransactionAsync( + It.IsAny>(), + It.IsAny(), + null + ) + ) + .Returns, CancellationToken, object?>((action, _, _) => action()); + + _busMock + .Setup(b => b.PublishAsync(It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + } + + [Fact] + public async Task CreateHandleAsync_WhenReferencesAreMissing_ReturnsMergedBatchFailure() + { + Guid missingCategoryId = Guid.NewGuid(); + Guid missingProductDataId = Guid.NewGuid(); + CreateProductsCommand command = new( + new CreateProductsRequest([ + new CreateProductRequest( + "Product", + "Desc", + 10m, + missingCategoryId, + [missingProductDataId] + ), + ]) + ); + + _categoryRepositoryMock + .Setup(r => + r.ListAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync([]); + _productDataRepositoryMock + .Setup(r => + r.GetByIdsAsync(It.IsAny>(), It.IsAny()) + ) + .ReturnsAsync([]); + + var (result, _) = await CreateProductsCommandHandler.HandleAsync( + command, + _productRepositoryMock.Object, + _categoryRepositoryMock.Object, + _productDataRepositoryMock.Object, + _unitOfWorkMock.Object, + _busMock.Object, + CreateBatchRule(new CreateProductRequestValidator()), + TimeProvider.System, + CancellationToken.None + ); + + result.IsError.ShouldBeFalse(); + result.Value.FailureCount.ShouldBe(1); + result.Value.Failures.ShouldHaveSingleItem(); + result + .Value.Failures[0] + .Errors.ShouldBe([ + string.Format(ErrorCatalog.Categories.NotFoundMessage, missingCategoryId), + string.Format(ErrorCatalog.ProductData.NotFoundMessage, missingProductDataId), + ]); + _productRepositoryMock.Verify( + r => + r.AddRangeAsync( + It.IsAny>(), + It.IsAny() + ), + Times.Never + ); + _busMock.Verify( + b => b.PublishAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Fact] + public async Task CreateHandleAsync_WhenItemsAreValid_PersistsProductsAndPublishesEvents() + { + Guid categoryId = Guid.NewGuid(); + Guid productDataId = Guid.NewGuid(); + List? persistedProducts = null; + CreateProductsCommand command = new( + new CreateProductsRequest([ + new CreateProductRequest( + "Camera", + "Mirrorless", + 499.99m, + categoryId, + [productDataId, productDataId] + ), + new CreateProductRequest("Lens", null, 199.99m, null, null), + ]) + ); + + _categoryRepositoryMock + .Setup(r => + r.ListAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync([new CategoryEntity { Id = categoryId, Name = "Photo" }]); + _productDataRepositoryMock + .Setup(r => + r.GetByIdsAsync(It.IsAny>(), It.IsAny()) + ) + .ReturnsAsync([new ImageProductData { Id = productDataId, Title = "Spec" }]); + _productRepositoryMock + .Setup(r => + r.AddRangeAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .Callback, CancellationToken>( + (products, _) => persistedProducts = products.ToList() + ) + .ReturnsAsync([]); + + var (result, _) = await CreateProductsCommandHandler.HandleAsync( + command, + _productRepositoryMock.Object, + _categoryRepositoryMock.Object, + _productDataRepositoryMock.Object, + _unitOfWorkMock.Object, + _busMock.Object, + CreateBatchRule(new CreateProductRequestValidator()), + TimeProvider.System, + CancellationToken.None + ); + + result.IsError.ShouldBeFalse(); + result.Value.SuccessCount.ShouldBe(2); + persistedProducts.ShouldNotBeNull(); + persistedProducts.Count.ShouldBe(2); + persistedProducts[0].CategoryId.ShouldBe(categoryId); + persistedProducts[0].ProductDataLinks.Count.ShouldBe(1); + persistedProducts[0].ProductDataLinks.Single().ProductDataId.ShouldBe(productDataId); + persistedProducts[1].ProductDataLinks.ShouldBeEmpty(); + _busMock.Verify( + b => + b.PublishAsync( + It.IsAny(), + It.IsAny() + ), + Times.Exactly(2) + ); + } + + [Fact] + public async Task LoadAsync_WhenProductIsMissing_ReturnsStopWithoutLookup() + { + Guid missingProductId = Guid.NewGuid(); + UpdateProductsCommand command = new( + new UpdateProductsRequest([ + new UpdateProductItem(missingProductId, "Updated", null, 10m), + ]) + ); + + _productRepositoryMock + .Setup(r => + r.ListAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync([]); + + var (continuation, lookup, _) = await UpdateProductsCommandHandler.LoadAsync( + command, + _productRepositoryMock.Object, + _categoryRepositoryMock.Object, + _productDataRepositoryMock.Object, + CreateBatchRule(new UpdateProductItemValidator()), + CancellationToken.None + ); + + continuation.ShouldBe(HandlerContinuation.Stop); + lookup.ShouldBeNull(); + } + + [Fact] + public async Task HandleAsync_WhenProductDataIdsAreProvided_SyncsLinksAndUpdatesProduct() + { + Guid productId = Guid.NewGuid(); + Guid oldProductDataId = Guid.NewGuid(); + Guid newProductDataId = Guid.NewGuid(); + ProductEntity product = new() + { + Id = productId, + Name = "Old", + Description = "Old Desc", + Price = 10m, + CategoryId = Guid.NewGuid(), + }; + product.ProductDataLinks.Add(ProductDataLink.Create(productId, oldProductDataId)); + + UpdateProductsCommand command = new( + new UpdateProductsRequest([ + new UpdateProductItem(productId, "New", "New Desc", 25m, null, [newProductDataId]), + ]) + ); + + var (result, _) = await UpdateProductsCommandHandler.HandleAsync( + command, + new SharedKernel.Application.Batch.EntityLookup( + new Dictionary { [productId] = product } + ), + _productRepositoryMock.Object, + _unitOfWorkMock.Object, + CancellationToken.None + ); + + result.IsError.ShouldBeFalse(); + result.Value.SuccessCount.ShouldBe(1); + product.Name.ShouldBe("New"); + product.Description.ShouldBe("New Desc"); + product.Price.ShouldBe(25m); + product.CategoryId.ShouldBeNull(); + product.ProductDataLinks.Count.ShouldBe(1); + product.ProductDataLinks.Single().ProductDataId.ShouldBe(newProductDataId); + } + + [Fact] + public async Task HandleAsync_WhenProductDataIdsAreNull_LeavesExistingLinksUntouched() + { + Guid productId = Guid.NewGuid(); + Guid existingLinkId = Guid.NewGuid(); + ProductEntity product = new() + { + Id = productId, + Name = "Old", + Price = 10m, + }; + product.ProductDataLinks.Add(ProductDataLink.Create(productId, existingLinkId)); + + UpdateProductsCommand command = new( + new UpdateProductsRequest([new UpdateProductItem(productId, "Renamed", null, 15m)]) + ); + + var (result, _) = await UpdateProductsCommandHandler.HandleAsync( + command, + new SharedKernel.Application.Batch.EntityLookup( + new Dictionary { [productId] = product } + ), + _productRepositoryMock.Object, + _unitOfWorkMock.Object, + CancellationToken.None + ); + + result.IsError.ShouldBeFalse(); + product.ProductDataLinks.Count.ShouldBe(1); + product.ProductDataLinks.Single().ProductDataId.ShouldBe(existingLinkId); + _productRepositoryMock.Verify( + r => + r.UpdateAsync( + It.Is(p => p.Id == productId), + It.IsAny() + ), + Times.Once + ); + } + + private static FluentValidationBatchRule CreateBatchRule( + FluentValidation.IValidator validator + ) + { + Mock metrics = new(); + return new FluentValidationBatchRule(validator, metrics.Object); + } +} diff --git a/tests/SharedKernel.Tests/Logging/ApiExceptionHandlerLogsTests.cs b/tests/SharedKernel.Tests/Logging/ApiExceptionHandlerLogsTests.cs new file mode 100644 index 00000000..41aad3b0 --- /dev/null +++ b/tests/SharedKernel.Tests/Logging/ApiExceptionHandlerLogsTests.cs @@ -0,0 +1,47 @@ +using System.Reflection; +using Microsoft.Extensions.Logging; +using SharedKernel.Api.ExceptionHandling; +using SharedKernel.Infrastructure.Logging; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Logging; + +public sealed class ApiExceptionHandlerLogsTests +{ + [Fact] + public void UnhandledException_UsesExpectedMessageAndClassifications() + { + MethodInfo method = typeof(ApiExceptionHandlerLogs).GetMethod( + nameof(ApiExceptionHandlerLogs.UnhandledException) + )!; + + LoggerMessageAttribute loggerMessage = method.GetCustomAttribute()!; + + loggerMessage.Level.ShouldBe(LogLevel.Error); + loggerMessage.Message.ShouldBe( + "Unhandled exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}" + ); + + method.GetParameters()[3].GetCustomAttribute().ShouldNotBeNull(); + method.GetParameters()[4].GetCustomAttribute().ShouldNotBeNull(); + } + + [Fact] + public void HandledApplicationException_UsesExpectedMessageAndClassifications() + { + MethodInfo method = typeof(ApiExceptionHandlerLogs).GetMethod( + nameof(ApiExceptionHandlerLogs.HandledApplicationException) + )!; + + LoggerMessageAttribute loggerMessage = method.GetCustomAttribute()!; + + loggerMessage.Level.ShouldBe(LogLevel.Warning); + loggerMessage.Message.ShouldBe( + "Handled application exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}" + ); + + method.GetParameters()[3].GetCustomAttribute().ShouldNotBeNull(); + method.GetParameters()[4].GetCustomAttribute().ShouldNotBeNull(); + } +} diff --git a/tests/SharedKernel.Tests/Logging/LogDataClassificationsTests.cs b/tests/SharedKernel.Tests/Logging/LogDataClassificationsTests.cs new file mode 100644 index 00000000..987c4755 --- /dev/null +++ b/tests/SharedKernel.Tests/Logging/LogDataClassificationsTests.cs @@ -0,0 +1,24 @@ +using SharedKernel.Infrastructure.Logging; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Logging; + +public sealed class LogDataClassificationsTests +{ + [Fact] + public void PersonalDataAttribute_MapsToPersonalClassification() + { + PersonalDataAttribute attribute = new(); + + attribute.Classification.ShouldBe(LogDataClassifications.Personal); + } + + [Fact] + public void SensitiveDataAttribute_MapsToSensitiveClassification() + { + SensitiveDataAttribute attribute = new(); + + attribute.Classification.ShouldBe(LogDataClassifications.Sensitive); + } +} diff --git a/tests/SharedKernel.Tests/Logging/RedactionConfigurationTests.cs b/tests/SharedKernel.Tests/Logging/RedactionConfigurationTests.cs new file mode 100644 index 00000000..6d6c6f4b --- /dev/null +++ b/tests/SharedKernel.Tests/Logging/RedactionConfigurationTests.cs @@ -0,0 +1,97 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SharedKernel.Api.Extensions; +using SharedKernel.Application.Options.Security; +using SharedKernel.Infrastructure.Logging; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Logging; + +public sealed class RedactionConfigurationTests +{ + [Fact] + public void ResolveHmacKey_WhenEnvironmentVariablePresent_PrefersEnvironmentValue() + { + RedactionOptions options = new() + { + HmacKeyEnvironmentVariable = "TEST_REDACTION_KEY", + HmacKey = "config-value", + }; + + string hmacKey = RedactionConfiguration.ResolveHmacKey( + options, + variable => variable == "TEST_REDACTION_KEY" ? "env-value" : null + ); + + hmacKey.ShouldBe("env-value"); + } + + [Fact] + public void ResolveHmacKey_WhenEnvironmentVariableMissing_UsesInlineConfiguration() + { + RedactionOptions options = new() + { + HmacKeyEnvironmentVariable = "TEST_REDACTION_KEY", + HmacKey = "config-value", + }; + + string hmacKey = RedactionConfiguration.ResolveHmacKey(options, _ => null); + + hmacKey.ShouldBe("config-value"); + } + + [Fact] + public void ResolveHmacKey_WhenNoSourceConfigured_Throws() + { + RedactionOptions options = new() { HmacKeyEnvironmentVariable = "TEST_REDACTION_KEY" }; + + InvalidOperationException exception = Should.Throw(() => + RedactionConfiguration.ResolveHmacKey(options, _ => null) + ); + + exception.Message.ShouldContain("TEST_REDACTION_KEY"); + } + + [Fact] + public void AddSharedLogRedaction_WithInlineHmacKey_RegistersLogging() + { + ServiceCollection services = new(); + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["Redaction:HmacKeyEnvironmentVariable"] = "UNUSED_REDACTION_KEY", + ["Redaction:HmacKey"] = "unit-test-hmac-key", + ["Redaction:KeyId"] = "1001", + } + ) + .Build(); + + services.AddSharedLogRedaction(configuration); + + services.ShouldContain(descriptor => descriptor.ServiceType == typeof(ILoggerFactory)); + } + + [Fact] + public void AddSharedLogRedaction_WhenMissingKey_Throws() + { + ServiceCollection services = new(); + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["Redaction:HmacKeyEnvironmentVariable"] = "NON_EXISTENT_TEST_REDACTION_KEY", + ["Redaction:KeyId"] = "1001", + } + ) + .Build(); + + InvalidOperationException exception = Should.Throw(() => + services.AddSharedLogRedaction(configuration) + ); + + exception.Message.ShouldContain("NON_EXISTENT_TEST_REDACTION_KEY"); + } +} diff --git a/tests/SharedKernel.Tests/Observability/CacheTelemetryTests.cs b/tests/SharedKernel.Tests/Observability/CacheTelemetryTests.cs index 3418d5f5..ca21f833 100644 --- a/tests/SharedKernel.Tests/Observability/CacheTelemetryTests.cs +++ b/tests/SharedKernel.Tests/Observability/CacheTelemetryTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using SharedKernel.Infrastructure.Observability; using Shouldly; using Xunit; @@ -9,6 +10,14 @@ public sealed class CacheTelemetryTests [Fact] public void StartOutputCacheInvalidationActivity_AddsCacheTag() { + using ActivityListener listener = new() + { + ShouldListenTo = source => source.Name == ObservabilityConventions.ActivitySourceName, + Sample = static (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllData, + }; + ActivitySource.AddActivityListener(listener); + using System.Diagnostics.Activity? activity = CacheTelemetry.StartOutputCacheInvalidationActivity("Products"); From 5efeca224d13c1e8a8319b7181c9511931dd48e2 Mon Sep 17 00:00:00 2001 From: Tadeas Zribko Date: Tue, 31 Mar 2026 12:40:43 +0200 Subject: [PATCH 05/14] Refactor Reviews and Webhooks Services - Updated Program.cs to use AddControllers instead of AddSharedControllers and added FluentValidation support in Wolverine. - Removed Redaction settings from appsettings.json in both Reviews and Webhooks services. - Modified ReviewsDbContext to include new dependencies for auditing and soft delete functionality. - Simplified health check registration in Webhooks service. - Updated DbContextDesignTimeFactory to use hardcoded connection strings for local development. - Refactored ApiExceptionHandler to improve logging and removed observability dependencies. - Cleaned up HostExtensions and KeycloakAuthExtensions by removing observability-related code. - Streamlined ObservabilityExtensions by removing unnecessary configurations and simplifying OpenTelemetry setup. - Adjusted OutputCachingExtensions to ensure proper service registration. - Removed unused observability and telemetry code from various files. - Introduced ErrorOrValidationMiddleware for handling validation errors in Wolverine handlers. - Enhanced SoftDeleteProcessor to improve soft delete logic and reduce complexity. - Updated TenantAuditableDbContext to use new dependency injection patterns. - Cleaned up RepositoryBase to clarify write method responsibilities. - Removed unnecessary project references and updated package dependencies in SharedKernel projects. --- src/Gateway/Gateway.Api/Program.cs | 2 +- src/Gateway/Gateway.Api/appsettings.json | 4 - .../BackgroundJobs.Api/Program.cs | 25 +- .../BackgroundJobs.Api/appsettings.json | 4 - ...ackgroundJobsDbContextDesignTimeFactory.cs | 7 +- .../Queue/JobProcessingBackgroundService.cs | 8 +- .../FileStorage/FileStorage.Api/Program.cs | 6 +- .../FileStorage.Api/appsettings.json | 4 - .../Persistence/FileStorageDbContext.cs | 21 +- .../FileStorageDbContextDesignTimeFactory.cs | 15 +- src/Services/Identity/Identity.Api/Program.cs | 11 +- .../Identity/Identity.Api/appsettings.json | 4 - .../Persistence/IdentityDbContext.cs | 22 +- .../IdentityDbContextDesignTimeFactory.cs | 14 +- .../Security/Keycloak/KeycloakAdminService.cs | 30 +- .../Keycloak/KeycloakAdminTokenProvider.cs | 6 +- .../Security/Tenant/TenantClaimValidator.cs | 15 +- .../Notifications.Api/Program.cs | 5 +- .../Notifications.Api/appsettings.json | 4 - ...NotificationsDbContextDesignTimeFactory.cs | 7 +- .../ProductCatalog.Api/Program.cs | 17 +- .../ProductCatalog.Api/appsettings.json | 4 - .../Commands/CreateCategoriesCommand.cs | 8 +- .../Commands/UpdateCategoriesCommand.cs | 48 +--- .../Product/Commands/CreateProductsCommand.cs | 8 +- .../Product/Commands/UpdateProductsCommand.cs | 46 +-- .../Commands/UpdateProductsValidator.cs | 77 +++++ .../Persistence/MongoDbContext.cs | 7 +- .../Persistence/ProductCatalogDbContext.cs | 20 +- ...roductCatalogDbContextDesignTimeFactory.cs | 14 +- src/Services/Reviews/Reviews.Api/Program.cs | 11 +- .../Reviews/Reviews.Api/appsettings.json | 4 - .../Persistence/ReviewsDbContext.cs | 20 +- .../ReviewsDbContextDesignTimeFactory.cs | 14 +- src/Services/Webhooks/Webhooks.Api/Program.cs | 5 +- .../Webhooks/Webhooks.Api/appsettings.json | 4 - .../WebhooksDbContextDesignTimeFactory.cs | 7 +- .../ExceptionHandling/ApiExceptionHandler.cs | 15 +- .../Extensions/HostExtensions.cs | 12 +- .../Extensions/KeycloakAuthExtensions.cs | 19 +- .../Extensions/ObservabilityExtensions.cs | 265 ++---------------- .../Extensions/OutputCachingExtensions.cs | 17 +- .../Extensions/SerilogExtensions.cs | 2 - .../Extensions/SharedServiceRegistration.cs | 24 -- .../WebApplicationPipelineExtensions.cs | 9 - .../FluentValidationActionFilter.cs | 15 +- .../OutputCacheInvalidationService.cs | 8 - .../TenantAwareOutputCachePolicy.cs | 15 +- .../SharedKernel.Api/SharedKernel.Api.csproj | 8 - .../Batch/Rules/FluentValidationBatchRule.cs | 18 +- .../Middleware/ErrorOrValidationMiddleware.cs | 53 ++++ .../SoftDelete/SoftDeleteProcessor.cs | 46 +-- .../Persistence/TenantAuditableDbContext.cs | 39 ++- .../Repositories/RepositoryBase.cs | 2 + .../SharedKernel.Infrastructure.csproj | 7 - .../RabbitMqConventionExtensions.cs | 6 +- .../SharedKernel.Messaging.csproj | 8 - 57 files changed, 455 insertions(+), 661 deletions(-) create mode 100644 src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsValidator.cs create mode 100644 src/SharedKernel/SharedKernel.Application/Middleware/ErrorOrValidationMiddleware.cs diff --git a/src/Gateway/Gateway.Api/Program.cs b/src/Gateway/Gateway.Api/Program.cs index 08ce7c99..35e36be1 100644 --- a/src/Gateway/Gateway.Api/Program.cs +++ b/src/Gateway/Gateway.Api/Program.cs @@ -14,7 +14,7 @@ WebApplication app = builder.Build(); app.MapReverseProxy(); -app.MapSharedHealthChecks(); +app.MapHealthChecks("/health"); app.MapGatewayScalarUi(); app.Run(); diff --git a/src/Gateway/Gateway.Api/appsettings.json b/src/Gateway/Gateway.Api/appsettings.json index 5f25b34c..570f9e99 100644 --- a/src/Gateway/Gateway.Api/appsettings.json +++ b/src/Gateway/Gateway.Api/appsettings.json @@ -230,9 +230,5 @@ } } } - }, - "Redaction": { - "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", - "KeyId": 1001 } } diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Api/Program.cs b/src/Services/BackgroundJobs/BackgroundJobs.Api/Program.cs index 2ec0333e..56c69b09 100644 --- a/src/Services/BackgroundJobs/BackgroundJobs.Api/Program.cs +++ b/src/Services/BackgroundJobs/BackgroundJobs.Api/Program.cs @@ -70,19 +70,17 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); -string? tickerQDragonflyConnectionString = null; - // TickerQ (when enabled) if (backgroundJobsOptions.TickerQ.Enabled) { - tickerQDragonflyConnectionString = builder.Configuration.GetConnectionString( + string? dragonflyConnectionString = builder.Configuration.GetConnectionString( backgroundJobsOptions.TickerQ.CoordinationConnection ); - if (!string.IsNullOrWhiteSpace(tickerQDragonflyConnectionString)) + if (!string.IsNullOrWhiteSpace(dragonflyConnectionString)) { builder.Services.AddSingleton( - ConnectionMultiplexer.Connect(tickerQDragonflyConnectionString) + ConnectionMultiplexer.Connect(dragonflyConnectionString) ); } @@ -128,23 +126,10 @@ } // Health checks -IHealthChecksBuilder backgroundJobsHealthChecks = builder - .Services.AddHealthChecks() - .AddPostgreSqlHealthCheck(connectionString) - .AddSharedRabbitMqHealthCheck(builder.Configuration); - -if (backgroundJobsOptions.TickerQ.Enabled) -{ - backgroundJobsHealthChecks.AddPostgreSqlHealthCheck( - connectionString, - SharedKernel.Infrastructure.Observability.HealthCheckNames.Scheduler, - SharedKernel.Infrastructure.Observability.HealthCheckTags.Scheduler - ); - backgroundJobsHealthChecks.AddDragonflyHealthCheck(tickerQDragonflyConnectionString); -} +builder.Services.AddHealthChecks(); // Controllers -builder.Services.AddSharedControllers(); +builder.Services.AddControllers(); builder.Services.AddSharedOpenApiDocumentation(); // Wolverine with RabbitMQ diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.json b/src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.json index 6f87b240..482403e3 100644 --- a/src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.json +++ b/src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.json @@ -54,9 +54,5 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } - }, - "Redaction": { - "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", - "KeyId": 1001 } } diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/BackgroundJobsDbContextDesignTimeFactory.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/BackgroundJobsDbContextDesignTimeFactory.cs index f88af10d..8a62170f 100644 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/BackgroundJobsDbContextDesignTimeFactory.cs +++ b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Persistence/BackgroundJobsDbContextDesignTimeFactory.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; -using SharedKernel.Infrastructure.Persistence; namespace BackgroundJobs.Infrastructure.Persistence; @@ -15,11 +14,7 @@ public BackgroundJobsDbContext CreateDbContext(string[] args) { DbContextOptionsBuilder optionsBuilder = new(); optionsBuilder.UseNpgsql( - DesignTimeConnectionStringResolver.Resolve( - "src/Services/BackgroundJobs/BackgroundJobs.Api", - "DefaultConnection", - args - ) + "Host=localhost;Database=background_jobs_db;Username=postgres;Password=postgres" ); return new BackgroundJobsDbContext(optionsBuilder.Options); diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Queue/JobProcessingBackgroundService.cs b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Queue/JobProcessingBackgroundService.cs index b24accbc..40b38e52 100644 --- a/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Queue/JobProcessingBackgroundService.cs +++ b/src/Services/BackgroundJobs/BackgroundJobs.Infrastructure/Queue/JobProcessingBackgroundService.cs @@ -19,9 +19,6 @@ public sealed class JobProcessingBackgroundService : QueueConsumerBackgroundServ private const int SimulatedStepDelayMs = 200; private const int ProgressPerStep = 20; private const string CompletedResultSummary = "Job completed successfully"; - private static readonly string SerializedCompletedResult = JsonSerializer.Serialize( - new { summary = CompletedResultSummary } - ); private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; @@ -64,7 +61,10 @@ protected override async Task ProcessItemAsync(Guid jobId, CancellationToken ct) await uow.CommitAsync(ct); } - job.MarkCompleted(SerializedCompletedResult, _timeProvider); + job.MarkCompleted( + JsonSerializer.Serialize(new { summary = CompletedResultSummary }), + _timeProvider + ); await uow.CommitAsync(ct); } diff --git a/src/Services/FileStorage/FileStorage.Api/Program.cs b/src/Services/FileStorage/FileStorage.Api/Program.cs index 9025c45b..4e42df5f 100644 --- a/src/Services/FileStorage/FileStorage.Api/Program.cs +++ b/src/Services/FileStorage/FileStorage.Api/Program.cs @@ -47,11 +47,7 @@ builder.Services.AddSharedOutputCaching(builder.Configuration); builder.Services.AddWolverineHttp(); -builder - .Services.AddHealthChecks() - .AddPostgreSqlHealthCheck(builder.Configuration.GetRequiredConnectionString("FileStorageDb")) - .AddDragonflyHealthCheck(builder.Configuration.GetConnectionString("Dragonfly")) - .AddSharedRabbitMqHealthCheck(builder.Configuration); +builder.Services.AddHealthChecks(); builder.Host.UseWolverine(opts => { diff --git a/src/Services/FileStorage/FileStorage.Api/appsettings.json b/src/Services/FileStorage/FileStorage.Api/appsettings.json index 11bc1bf4..a05ee5f8 100644 --- a/src/Services/FileStorage/FileStorage.Api/appsettings.json +++ b/src/Services/FileStorage/FileStorage.Api/appsettings.json @@ -39,9 +39,5 @@ "RetryEnabled": true, "RetryCount": 3, "RetryDelaySeconds": 5 - }, - "Redaction": { - "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", - "KeyId": 1001 } } diff --git a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContext.cs b/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContext.cs index ba8611c9..293eda45 100644 --- a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContext.cs +++ b/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContext.cs @@ -1,7 +1,10 @@ using FileStorage.Domain.Entities; using Microsoft.EntityFrameworkCore; +using SharedKernel.Application.Context; using SharedKernel.Infrastructure.Persistence; +using SharedKernel.Infrastructure.Persistence.Auditing; using SharedKernel.Infrastructure.Persistence.EntityNormalization; +using SharedKernel.Infrastructure.Persistence.SoftDelete; namespace FileStorage.Infrastructure.Persistence; @@ -13,10 +16,24 @@ public sealed class FileStorageDbContext : TenantAuditableDbContext { public FileStorageDbContext( DbContextOptions options, - TenantAuditableDbContextDependencies deps, + ITenantProvider tenantProvider, + IActorProvider actorProvider, + TimeProvider timeProvider, + IEnumerable softDeleteCascadeRules, + IAuditableEntityStateManager entityStateManager, + ISoftDeleteProcessor softDeleteProcessor, IEntityNormalizationService? entityNormalizationService = null ) - : base(options, deps, entityNormalizationService) { } + : base( + options, + tenantProvider, + actorProvider, + timeProvider, + softDeleteCascadeRules, + entityStateManager, + softDeleteProcessor, + entityNormalizationService + ) { } public DbSet StoredFiles => Set(); diff --git a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContextDesignTimeFactory.cs b/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContextDesignTimeFactory.cs index a55119fe..94bd616e 100644 --- a/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContextDesignTimeFactory.cs +++ b/src/Services/FileStorage/FileStorage.Infrastructure/Persistence/FileStorageDbContextDesignTimeFactory.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; -using SharedKernel.Infrastructure.Persistence; namespace FileStorage.Infrastructure.Persistence; @@ -15,17 +14,17 @@ public FileStorageDbContext CreateDbContext(string[] args) { DbContextOptionsBuilder optionsBuilder = new(); optionsBuilder.UseNpgsql( - DesignTimeConnectionStringResolver.Resolve( - "src/Services/FileStorage/FileStorage.Api", - "FileStorageDb", - args - ) + "Host=localhost;Database=file_storage_db;Username=postgres;Password=postgres" ); return new FileStorageDbContext( optionsBuilder.Options, - DesignTimeDbContextDefaults.CreateDependencies(), - DesignTimeDbContextDefaults.EntityNormalizationService + tenantProvider: null!, + actorProvider: null!, + timeProvider: TimeProvider.System, + softDeleteCascadeRules: [], + entityStateManager: null!, + softDeleteProcessor: null! ); } } diff --git a/src/Services/Identity/Identity.Api/Program.cs b/src/Services/Identity/Identity.Api/Program.cs index 554a7e79..9fd4f554 100644 --- a/src/Services/Identity/Identity.Api/Program.cs +++ b/src/Services/Identity/Identity.Api/Program.cs @@ -23,6 +23,7 @@ using SharedKernel.Messaging.Topology; using Wolverine; using Wolverine.EntityFrameworkCore; +using Wolverine.FluentValidation; using Wolverine.Postgresql; using Wolverine.RabbitMQ; @@ -102,21 +103,19 @@ builder.Services.AddValidatorsFromAssemblyContaining(); -builder.Services.AddSharedControllers(); +builder.Services.AddControllers(); builder.Services.AddSharedOpenApiDocumentation(); builder.Services.AddSharedOutputCaching(builder.Configuration); -builder - .Services.AddHealthChecks() - .AddPostgreSqlHealthCheck(builder.Configuration.GetRequiredConnectionString("IdentityDb")) - .AddDragonflyHealthCheck(builder.Configuration.GetConnectionString("Dragonfly")) - .AddSharedRabbitMqHealthCheck(builder.Configuration); +builder.Services.AddHealthChecks(); builder.Host.UseWolverine(opts => { opts.ApplySharedConventions(); opts.ApplySharedRetryPolicies(); + opts.UseFluentValidation(); + opts.Discovery.IncludeAssembly(typeof(IKeycloakAdminService).Assembly); opts.Discovery.IncludeAssembly(typeof(CacheInvalidationHandler).Assembly); diff --git a/src/Services/Identity/Identity.Api/appsettings.json b/src/Services/Identity/Identity.Api/appsettings.json index 3bc0ab59..24180a1d 100644 --- a/src/Services/Identity/Identity.Api/appsettings.json +++ b/src/Services/Identity/Identity.Api/appsettings.json @@ -54,9 +54,5 @@ "RetryEnabled": true, "RetryCount": 3, "RetryDelaySeconds": 5 - }, - "Redaction": { - "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", - "KeyId": 1001 } } diff --git a/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContext.cs b/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContext.cs index d80077ff..f42d49e5 100644 --- a/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContext.cs +++ b/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContext.cs @@ -1,21 +1,37 @@ using Identity.Application.Sagas; using Identity.Domain.Entities; using Microsoft.EntityFrameworkCore; +using SharedKernel.Application.Context; using SharedKernel.Infrastructure.Persistence; +using SharedKernel.Infrastructure.Persistence.Auditing; +using SharedKernel.Infrastructure.Persistence.SoftDelete; namespace Identity.Infrastructure.Persistence; /// -/// EF Core context for Identity & Tenancy microservice. +/// EF Core context for Identity & Tenancy microservice. /// Enforces multi-tenancy, audit stamping, soft delete, and optimistic concurrency. /// public sealed class IdentityDbContext : TenantAuditableDbContext { public IdentityDbContext( DbContextOptions options, - TenantAuditableDbContextDependencies deps + ITenantProvider tenantProvider, + IActorProvider actorProvider, + TimeProvider timeProvider, + IEnumerable softDeleteCascadeRules, + IAuditableEntityStateManager entityStateManager, + ISoftDeleteProcessor softDeleteProcessor ) - : base(options, deps) { } + : base( + options, + tenantProvider, + actorProvider, + timeProvider, + softDeleteCascadeRules, + entityStateManager, + softDeleteProcessor + ) { } public DbSet Tenants => Set(); public DbSet Users => Set(); diff --git a/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContextDesignTimeFactory.cs b/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContextDesignTimeFactory.cs index afd99329..c42bd74e 100644 --- a/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContextDesignTimeFactory.cs +++ b/src/Services/Identity/Identity.Infrastructure/Persistence/IdentityDbContextDesignTimeFactory.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; -using SharedKernel.Infrastructure.Persistence; namespace Identity.Infrastructure.Persistence; @@ -15,16 +14,17 @@ public IdentityDbContext CreateDbContext(string[] args) { DbContextOptionsBuilder optionsBuilder = new(); optionsBuilder.UseNpgsql( - DesignTimeConnectionStringResolver.Resolve( - "src/Services/Identity/Identity.Api", - "IdentityDb", - args - ) + "Host=localhost;Database=identity_db;Username=postgres;Password=postgres" ); return new IdentityDbContext( optionsBuilder.Options, - DesignTimeDbContextDefaults.CreateDependencies() + tenantProvider: null!, + actorProvider: null!, + timeProvider: TimeProvider.System, + softDeleteCascadeRules: [], + entityStateManager: null!, + softDeleteProcessor: null! ); } } diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminService.cs b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminService.cs index 2a1b5ab4..9676ebb5 100644 --- a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminService.cs +++ b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminService.cs @@ -52,7 +52,11 @@ public async Task CreateUserAsync( string keycloakUserId = ExtractUserIdFromLocation(response); - _logger.UserCreated(username, keycloakUserId); + _logger.LogInformation( + "Created Keycloak user {Username} with id {KeycloakUserId}", + username, + keycloakUserId + ); try { @@ -72,7 +76,11 @@ await _userClient.ExecuteActionsEmailAsync( } catch (Exception ex) when (ex is not OperationCanceledException) { - _logger.SetupEmailFailed(ex, keycloakUserId); + _logger.LogWarning( + ex, + "Failed to send setup email for Keycloak user {KeycloakUserId}. User was created but has no setup email.", + keycloakUserId + ); } return keycloakUserId; @@ -93,7 +101,10 @@ await _userClient.ExecuteActionsEmailAsync( ct ); - _logger.PasswordResetEmailSent(keycloakUserId); + _logger.LogInformation( + "Sent password reset email to Keycloak user {KeycloakUserId}", + keycloakUserId + ); } public async Task SetUserEnabledAsync( @@ -105,7 +116,11 @@ public async Task SetUserEnabledAsync( UserRepresentation patch = new() { Enabled = enabled }; await _userClient.UpdateUserAsync(_realm, keycloakUserId, patch, ct); - _logger.UserEnabledStateChanged(keycloakUserId, enabled); + _logger.LogInformation( + "Set Keycloak user {KeycloakUserId} enabled={Enabled}", + keycloakUserId, + enabled + ); } public async Task DeleteUserAsync(string keycloakUserId, CancellationToken ct = default) @@ -116,11 +131,14 @@ public async Task DeleteUserAsync(string keycloakUserId, CancellationToken ct = } catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) { - _logger.UserDeleteNotFound(keycloakUserId); + _logger.LogWarning( + "Keycloak user {KeycloakUserId} was not found during delete — treating as already deleted.", + keycloakUserId + ); return; } - _logger.UserDeleted(keycloakUserId); + _logger.LogInformation("Deleted Keycloak user {KeycloakUserId}", keycloakUserId); } private static string ExtractUserIdFromLocation(HttpResponseMessage response) diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs index 7d759089..c2aac104 100644 --- a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs +++ b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs @@ -94,7 +94,11 @@ private async Task FetchTokenAsync(CancellationToken canc if (!response.IsSuccessStatusCode) { string body = await response.Content.ReadAsStringAsync(cancellationToken); - _logger.TokenAcquireFailed((int)response.StatusCode, body); + _logger.LogError( + "Failed to acquire Keycloak admin token. Status: {Status}. Body: {Body}", + (int)response.StatusCode, + body + ); response.EnsureSuccessStatusCode(); } diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidator.cs b/src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidator.cs index 4d320ab1..cd52798c 100644 --- a/src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidator.cs +++ b/src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidator.cs @@ -101,7 +101,10 @@ public static bool HasValidTenantClaim(ClaimsPrincipal? principal) .RequestServices.GetRequiredService() .CreateLogger(typeof(TenantClaimValidator)); - logger.UserProvisioningFailed(ex); + logger.LogWarning( + ex, + "User provisioning failed during token validation — authentication will continue" + ); return null; } @@ -143,7 +146,7 @@ string scheme if (principal?.Identity is not ClaimsIdentity identity) { - logger.TokenValidatedNoIdentity(scheme); + logger.LogWarning("[{Scheme}] Token validated but no identity found", scheme); return; } @@ -151,6 +154,12 @@ string scheme string[] roles = identity.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray(); string? tenantId = identity.FindFirst(AuthConstants.Claims.TenantId)?.Value; - logger.UserAuthenticated(scheme, name, tenantId, string.Join(", ", roles)); + logger.LogInformation( + "[{Scheme}] Authenticated user={User}, tenant={TenantId}, roles=[{Roles}]", + scheme, + name, + tenantId, + string.Join(", ", roles) + ); } } diff --git a/src/Services/Notifications/Notifications.Api/Program.cs b/src/Services/Notifications/Notifications.Api/Program.cs index 1fb32d62..16ad5c73 100644 --- a/src/Services/Notifications/Notifications.Api/Program.cs +++ b/src/Services/Notifications/Notifications.Api/Program.cs @@ -53,10 +53,7 @@ builder.Services.AddScoped(); // Health checks -builder - .Services.AddHealthChecks() - .AddPostgreSqlHealthCheck(connectionString) - .AddSharedRabbitMqHealthCheck(builder.Configuration); +builder.Services.AddHealthChecks(); builder.Services.AddSharedOpenApiDocumentation(); builder.Services.AddWolverineHttp(); diff --git a/src/Services/Notifications/Notifications.Api/appsettings.json b/src/Services/Notifications/Notifications.Api/appsettings.json index dba3e2ff..4cdae3f3 100644 --- a/src/Services/Notifications/Notifications.Api/appsettings.json +++ b/src/Services/Notifications/Notifications.Api/appsettings.json @@ -37,9 +37,5 @@ "LogLevel": { "Default": "Information" } - }, - "Redaction": { - "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", - "KeyId": 1001 } } diff --git a/src/Services/Notifications/Notifications.Infrastructure/Persistence/NotificationsDbContextDesignTimeFactory.cs b/src/Services/Notifications/Notifications.Infrastructure/Persistence/NotificationsDbContextDesignTimeFactory.cs index ea979b34..3df44be6 100644 --- a/src/Services/Notifications/Notifications.Infrastructure/Persistence/NotificationsDbContextDesignTimeFactory.cs +++ b/src/Services/Notifications/Notifications.Infrastructure/Persistence/NotificationsDbContextDesignTimeFactory.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; -using SharedKernel.Infrastructure.Persistence; namespace Notifications.Infrastructure.Persistence; @@ -15,11 +14,7 @@ public NotificationsDbContext CreateDbContext(string[] args) { DbContextOptionsBuilder optionsBuilder = new(); optionsBuilder.UseNpgsql( - DesignTimeConnectionStringResolver.Resolve( - "src/Services/Notifications/Notifications.Api", - "DefaultConnection", - args - ) + "Host=localhost;Database=notifications_db;Username=postgres;Password=postgres" ); return new NotificationsDbContext(optionsBuilder.Options); diff --git a/src/Services/ProductCatalog/ProductCatalog.Api/Program.cs b/src/Services/ProductCatalog/ProductCatalog.Api/Program.cs index 975f34cd..fc5aa171 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Api/Program.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Api/Program.cs @@ -2,7 +2,6 @@ using FluentValidation; using Microsoft.EntityFrameworkCore; using Polly; -using ProductCatalog.Api.Health; using ProductCatalog.Application.Features.Product.Repositories; using ProductCatalog.Application.Features.Product.Validation; using ProductCatalog.Application.Sagas; @@ -17,6 +16,7 @@ using SharedKernel.Messaging.Topology; using Wolverine; using Wolverine.EntityFrameworkCore; +using Wolverine.FluentValidation; using Wolverine.Postgresql; using Wolverine.RabbitMQ; @@ -40,7 +40,6 @@ MongoDbSettings.SectionName ); builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSharedInfrastructure(builder.Configuration); @@ -71,25 +70,19 @@ builder.Services.AddValidatorsFromAssemblyContaining(); -builder.Services.AddSharedControllers(); +builder.Services.AddControllers(); builder.Services.AddSharedOpenApiDocumentation(); builder.Services.AddSharedOutputCaching(builder.Configuration); -builder - .Services.AddHealthChecks() - .AddPostgreSqlHealthCheck(builder.Configuration.GetRequiredConnectionString("ProductCatalogDb")) - .AddDragonflyHealthCheck(builder.Configuration.GetConnectionString("Dragonfly")) - .AddCheck( - SharedKernel.Infrastructure.Observability.HealthCheckNames.MongoDb, - tags: SharedKernel.Infrastructure.Observability.HealthCheckTags.Database - ) - .AddSharedRabbitMqHealthCheck(builder.Configuration); +builder.Services.AddHealthChecks(); builder.Host.UseWolverine(opts => { opts.ApplySharedConventions(); opts.ApplySharedRetryPolicies(); + opts.UseFluentValidation(); + opts.Discovery.IncludeAssembly(typeof(ProductDeletionSaga).Assembly); opts.Discovery.IncludeAssembly(typeof(CacheInvalidationHandler).Assembly); diff --git a/src/Services/ProductCatalog/ProductCatalog.Api/appsettings.json b/src/Services/ProductCatalog/ProductCatalog.Api/appsettings.json index a333bdb3..7a697a41 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Api/appsettings.json +++ b/src/Services/ProductCatalog/ProductCatalog.Api/appsettings.json @@ -38,9 +38,5 @@ "RetryEnabled": true, "RetryCount": 3, "RetryDelaySeconds": 5 - }, - "Redaction": { - "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", - "KeyId": 1001 } } diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/CreateCategoriesCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/CreateCategoriesCommand.cs index 0457a447..f9ecef60 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/CreateCategoriesCommand.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/CreateCategoriesCommand.cs @@ -1,4 +1,5 @@ using ErrorOr; +using FluentValidation; using ProductCatalog.Application.Features.Category.DTOs; using ProductCatalog.Domain.Interfaces; using SharedKernel.Application.Batch; @@ -21,14 +22,17 @@ public sealed class CreateCategoriesCommandHandler CreateCategoriesCommand command, ICategoryRepository repository, IUnitOfWork unitOfWork, - FluentValidationBatchRule batchRule, + IValidator itemValidator, CancellationToken ct ) { IReadOnlyList items = command.Request.Items; BatchFailureContext context = new(items); - await context.ApplyRulesAsync(ct, batchRule); + await context.ApplyRulesAsync( + ct, + new FluentValidationBatchRule(itemValidator) + ); if (context.HasFailures) return (context.ToFailureResponse(), CacheInvalidationCascades.None); diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/UpdateCategoriesCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/UpdateCategoriesCommand.cs index 8bbcca9c..118c7bb5 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/UpdateCategoriesCommand.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/UpdateCategoriesCommand.cs @@ -1,4 +1,5 @@ using ErrorOr; +using FluentValidation; using ProductCatalog.Application.Common.Errors; using ProductCatalog.Application.Features.Category.DTOs; using ProductCatalog.Application.Features.Category.Specifications; @@ -19,26 +20,22 @@ public sealed record UpdateCategoriesCommand(UpdateCategoriesRequest Request); /// Handles by validating all items, loading categories in bulk, and updating in a single transaction. public sealed class UpdateCategoriesCommandHandler { - /// - /// Wolverine compound-handler load step: validates and loads categories, short-circuiting the - /// handler pipeline with a failure response when any validation rule fails. - /// - public static async Task<( - HandlerContinuation, - EntityLookup?, - OutgoingMessages - )> LoadAsync( + public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( UpdateCategoriesCommand command, ICategoryRepository repository, - FluentValidationBatchRule batchRule, + IUnitOfWork unitOfWork, + IValidator itemValidator, CancellationToken ct ) { IReadOnlyList items = command.Request.Items; BatchFailureContext context = new(items); + await context.ApplyRulesAsync( + ct, + new FluentValidationBatchRule(itemValidator) + ); - await context.ApplyRulesAsync(ct, batchRule); - + // Load all target categories and mark missing ones as failed HashSet requestedIds = items .Where((_, i) => !context.IsFailed(i)) .Select(item => item.Id) @@ -56,33 +53,10 @@ await context.ApplyRulesAsync( ) ); - OutgoingMessages messages = new(); - if (context.HasFailures) - { - messages.RespondToSender(context.ToFailureResponse()); - return (HandlerContinuation.Stop, null, messages); - } - - return ( - HandlerContinuation.Continue, - new EntityLookup(categoryMap), - messages - ); - } - - /// Applies changes in a single transaction. - public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( - UpdateCategoriesCommand command, - EntityLookup lookup, - ICategoryRepository repository, - IUnitOfWork unitOfWork, - CancellationToken ct - ) - { - IReadOnlyList items = command.Request.Items; - IReadOnlyDictionary categoryMap = lookup.Entities; + return (context.ToFailureResponse(), CacheInvalidationCascades.None); + // Apply changes in a single transaction await unitOfWork.ExecuteInTransactionAsync( async () => { diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/CreateProductsCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/CreateProductsCommand.cs index 1cd00d26..bbcec8c2 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/CreateProductsCommand.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/CreateProductsCommand.cs @@ -1,5 +1,6 @@ using Contracts.IntegrationEvents.ProductCatalog; using ErrorOr; +using FluentValidation; using ProductCatalog.Application.Features.Product.DTOs; using ProductCatalog.Application.Features.Product.Repositories; using ProductCatalog.Domain.Entities; @@ -27,7 +28,7 @@ public sealed class CreateProductsCommandHandler IProductDataRepository productDataRepository, IUnitOfWork unitOfWork, IMessageBus bus, - FluentValidationBatchRule batchRule, + IValidator itemValidator, TimeProvider timeProvider, CancellationToken ct ) @@ -35,7 +36,10 @@ CancellationToken ct IReadOnlyList items = command.Request.Items; BatchFailureContext context = new(items); - await context.ApplyRulesAsync(ct, batchRule); + await context.ApplyRulesAsync( + ct, + new FluentValidationBatchRule(itemValidator) + ); // Reference checks skip only fluent-validation failures so both category and // product-data issues can be reported for the same index (merged into one failure row). diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsCommand.cs index c8a4a791..f354e462 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsCommand.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsCommand.cs @@ -1,12 +1,10 @@ using ErrorOr; -using ProductCatalog.Application.Common.Errors; +using FluentValidation; using ProductCatalog.Application.Features.Product.DTOs; using ProductCatalog.Application.Features.Product.Repositories; -using ProductCatalog.Application.Features.Product.Specifications; using ProductCatalog.Domain.Entities; using ProductCatalog.Domain.Interfaces; using SharedKernel.Application.Batch; -using SharedKernel.Application.Batch.Rules; using SharedKernel.Application.Common.Events; using SharedKernel.Application.DTOs; using SharedKernel.Domain.Interfaces; @@ -34,53 +32,31 @@ public sealed class UpdateProductsCommandHandler IProductRepository repository, ICategoryRepository categoryRepository, IProductDataRepository productDataRepository, - FluentValidationBatchRule batchRule, + IValidator itemValidator, CancellationToken ct ) { - IReadOnlyList items = command.Request.Items; - BatchFailureContext context = new(items); - - await context.ApplyRulesAsync(ct, batchRule); - - HashSet requestedIds = items - .Where((_, i) => !context.IsFailed(i)) - .Select(item => item.Id) - .ToHashSet(); - Dictionary productMap = ( - await repository.ListAsync(new ProductsByIdsWithLinksSpecification(requestedIds), ct) - ).ToDictionary(p => p.Id); - - await context.ApplyRulesAsync( - ct, - new MarkMissingByIdBatchRule( - item => item.Id, - productMap.Keys.ToHashSet(), - ErrorCatalog.Products.NotFoundMessage - ) - ); - - context.AddFailures( - await ProductValidationHelper.CheckProductReferencesAsync( - items, + (BatchResponse? failure, Dictionary? productMap) = + await UpdateProductsValidator.ValidateAndLoadAsync( + command, + repository, categoryRepository, productDataRepository, - context.FailedIndices, + itemValidator, ct - ) - ); + ); OutgoingMessages messages = new(); - if (context.HasFailures) + if (failure is not null) { - messages.RespondToSender(context.ToFailureResponse()); + messages.RespondToSender(failure); return (HandlerContinuation.Stop, null, messages); } return ( HandlerContinuation.Continue, - new EntityLookup(productMap), + new EntityLookup(productMap!), messages ); } diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsValidator.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsValidator.cs new file mode 100644 index 00000000..9c89b8d1 --- /dev/null +++ b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Product/Commands/UpdateProductsValidator.cs @@ -0,0 +1,77 @@ +using FluentValidation; +using ProductCatalog.Application.Common.Errors; +using ProductCatalog.Application.Features.Product.DTOs; +using ProductCatalog.Application.Features.Product.Repositories; +using ProductCatalog.Application.Features.Product.Specifications; +using ProductCatalog.Domain.Interfaces; +using SharedKernel.Application.Batch; +using SharedKernel.Application.Batch.Rules; +using SharedKernel.Application.DTOs; +using ProductEntity = ProductCatalog.Domain.Entities.Product; + +namespace ProductCatalog.Application.Features.Product.Commands; + +/// +/// Validates all items in an and loads target products. +/// Returns a failure when any rule fails, or null on the +/// happy path together with the loaded product map. +/// +internal static class UpdateProductsValidator +{ + internal static async Task<( + BatchResponse? Failure, + Dictionary? ProductMap + )> ValidateAndLoadAsync( + UpdateProductsCommand command, + IProductRepository repository, + ICategoryRepository categoryRepository, + IProductDataRepository productDataRepository, + IValidator itemValidator, + CancellationToken ct + ) + { + IReadOnlyList items = command.Request.Items; + BatchFailureContext context = new(items); + + // Validate each item (field-level rules — name, price, etc.) + await context.ApplyRulesAsync( + ct, + new FluentValidationBatchRule(itemValidator) + ); + + // Load all target products and mark missing ones as failed + HashSet requestedIds = items + .Where((_, i) => !context.IsFailed(i)) + .Select(item => item.Id) + .ToHashSet(); + Dictionary productMap = ( + await repository.ListAsync(new ProductsByIdsWithLinksSpecification(requestedIds), ct) + ).ToDictionary(p => p.Id); + + await context.ApplyRulesAsync( + ct, + new MarkMissingByIdBatchRule( + item => item.Id, + productMap.Keys.ToHashSet(), + ErrorCatalog.Products.NotFoundMessage + ) + ); + + // Reference checks skip only earlier failures (validation + missing entity) so + // category and product-data issues on the same row are merged into one failure. + context.AddFailures( + await ProductValidationHelper.CheckProductReferencesAsync( + items, + categoryRepository, + productDataRepository, + context.FailedIndices, + ct + ) + ); + + if (context.HasFailures) + return (context.ToFailureResponse(), null); + + return (null, productMap); + } +} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs index 8be2cc42..cecb2d35 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs @@ -10,12 +10,7 @@ namespace ProductCatalog.Infrastructure.Persistence; /// Thin wrapper around the MongoDB driver that configures the client with diagnostic /// activity tracing and exposes typed collection accessors for domain document types. /// -public interface IMongoDbHealthProbe -{ - Task PingAsync(CancellationToken cancellationToken = default); -} - -public sealed class MongoDbContext : IMongoDbHealthProbe +public sealed class MongoDbContext { private readonly IMongoDatabase _database; diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContext.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContext.cs index 0fadf618..0b3b4ba6 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContext.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContext.cs @@ -1,7 +1,10 @@ using Microsoft.EntityFrameworkCore; using ProductCatalog.Application.Sagas; using ProductCatalog.Domain.Entities; +using SharedKernel.Application.Context; using SharedKernel.Infrastructure.Persistence; +using SharedKernel.Infrastructure.Persistence.Auditing; +using SharedKernel.Infrastructure.Persistence.SoftDelete; namespace ProductCatalog.Infrastructure.Persistence; @@ -13,9 +16,22 @@ public sealed class ProductCatalogDbContext : TenantAuditableDbContext { public ProductCatalogDbContext( DbContextOptions options, - TenantAuditableDbContextDependencies deps + ITenantProvider tenantProvider, + IActorProvider actorProvider, + TimeProvider timeProvider, + IEnumerable softDeleteCascadeRules, + IAuditableEntityStateManager entityStateManager, + ISoftDeleteProcessor softDeleteProcessor ) - : base(options, deps) { } + : base( + options, + tenantProvider, + actorProvider, + timeProvider, + softDeleteCascadeRules, + entityStateManager, + softDeleteProcessor + ) { } public DbSet Products => Set(); public DbSet ProductDataLinks => Set(); diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContextDesignTimeFactory.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContextDesignTimeFactory.cs index 19a7ff64..f291b874 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContextDesignTimeFactory.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/ProductCatalogDbContextDesignTimeFactory.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; -using SharedKernel.Infrastructure.Persistence; namespace ProductCatalog.Infrastructure.Persistence; @@ -15,16 +14,17 @@ public ProductCatalogDbContext CreateDbContext(string[] args) { DbContextOptionsBuilder optionsBuilder = new(); optionsBuilder.UseNpgsql( - DesignTimeConnectionStringResolver.Resolve( - "src/Services/ProductCatalog/ProductCatalog.Api", - "ProductCatalogDb", - args - ) + "Host=localhost;Database=productcatalog_db;Username=postgres;Password=postgres" ); return new ProductCatalogDbContext( optionsBuilder.Options, - DesignTimeDbContextDefaults.CreateDependencies() + tenantProvider: null!, + actorProvider: null!, + timeProvider: TimeProvider.System, + softDeleteCascadeRules: [], + entityStateManager: null!, + softDeleteProcessor: null! ); } } diff --git a/src/Services/Reviews/Reviews.Api/Program.cs b/src/Services/Reviews/Reviews.Api/Program.cs index 1a1a89fa..25d71398 100644 --- a/src/Services/Reviews/Reviews.Api/Program.cs +++ b/src/Services/Reviews/Reviews.Api/Program.cs @@ -13,6 +13,7 @@ using SharedKernel.Messaging.Topology; using Wolverine; using Wolverine.EntityFrameworkCore; +using Wolverine.FluentValidation; using Wolverine.Postgresql; using Wolverine.RabbitMQ; @@ -38,21 +39,19 @@ builder.Services.AddValidatorsFromAssemblyContaining(); -builder.Services.AddSharedControllers(); +builder.Services.AddControllers(); builder.Services.AddSharedOpenApiDocumentation(); builder.Services.AddSharedOutputCaching(builder.Configuration); -builder - .Services.AddHealthChecks() - .AddPostgreSqlHealthCheck(builder.Configuration.GetRequiredConnectionString("ReviewsDb")) - .AddDragonflyHealthCheck(builder.Configuration.GetConnectionString("Dragonfly")) - .AddSharedRabbitMqHealthCheck(builder.Configuration); +builder.Services.AddHealthChecks(); builder.Host.UseWolverine(opts => { opts.ApplySharedConventions(); opts.ApplySharedRetryPolicies(); + opts.UseFluentValidation(); + opts.Discovery.IncludeAssembly(typeof(ProductCreatedEventHandler).Assembly); opts.Discovery.IncludeAssembly(typeof(CacheInvalidationHandler).Assembly); diff --git a/src/Services/Reviews/Reviews.Api/appsettings.json b/src/Services/Reviews/Reviews.Api/appsettings.json index dc8d8dc1..8ddd73ce 100644 --- a/src/Services/Reviews/Reviews.Api/appsettings.json +++ b/src/Services/Reviews/Reviews.Api/appsettings.json @@ -34,9 +34,5 @@ "RetryEnabled": true, "RetryCount": 3, "RetryDelaySeconds": 5 - }, - "Redaction": { - "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", - "KeyId": 1001 } } diff --git a/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContext.cs b/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContext.cs index 69ea16ef..1933ab7a 100644 --- a/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContext.cs +++ b/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContext.cs @@ -1,6 +1,9 @@ using Microsoft.EntityFrameworkCore; using Reviews.Domain.Entities; +using SharedKernel.Application.Context; using SharedKernel.Infrastructure.Persistence; +using SharedKernel.Infrastructure.Persistence.Auditing; +using SharedKernel.Infrastructure.Persistence.SoftDelete; namespace Reviews.Infrastructure.Persistence; @@ -12,9 +15,22 @@ public sealed class ReviewsDbContext : TenantAuditableDbContext { public ReviewsDbContext( DbContextOptions options, - TenantAuditableDbContextDependencies deps + ITenantProvider tenantProvider, + IActorProvider actorProvider, + TimeProvider timeProvider, + IEnumerable softDeleteCascadeRules, + IAuditableEntityStateManager entityStateManager, + ISoftDeleteProcessor softDeleteProcessor ) - : base(options, deps) { } + : base( + options, + tenantProvider, + actorProvider, + timeProvider, + softDeleteCascadeRules, + entityStateManager, + softDeleteProcessor + ) { } public DbSet ProductReviews => Set(); public DbSet ProductProjections => Set(); diff --git a/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContextDesignTimeFactory.cs b/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContextDesignTimeFactory.cs index a19fa33e..f0967f3e 100644 --- a/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContextDesignTimeFactory.cs +++ b/src/Services/Reviews/Reviews.Infrastructure/Persistence/ReviewsDbContextDesignTimeFactory.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; -using SharedKernel.Infrastructure.Persistence; namespace Reviews.Infrastructure.Persistence; @@ -15,16 +14,17 @@ public ReviewsDbContext CreateDbContext(string[] args) { DbContextOptionsBuilder optionsBuilder = new(); optionsBuilder.UseNpgsql( - DesignTimeConnectionStringResolver.Resolve( - "src/Services/Reviews/Reviews.Api", - "ReviewsDb", - args - ) + "Host=localhost;Database=reviews_db;Username=postgres;Password=postgres" ); return new ReviewsDbContext( optionsBuilder.Options, - DesignTimeDbContextDefaults.CreateDependencies() + tenantProvider: null!, + actorProvider: null!, + timeProvider: TimeProvider.System, + softDeleteCascadeRules: [], + entityStateManager: null!, + softDeleteProcessor: null! ); } } diff --git a/src/Services/Webhooks/Webhooks.Api/Program.cs b/src/Services/Webhooks/Webhooks.Api/Program.cs index c5bc6fcf..b8447e7f 100644 --- a/src/Services/Webhooks/Webhooks.Api/Program.cs +++ b/src/Services/Webhooks/Webhooks.Api/Program.cs @@ -66,10 +66,7 @@ ); // Health checks -builder - .Services.AddHealthChecks() - .AddPostgreSqlHealthCheck(connectionString) - .AddSharedRabbitMqHealthCheck(builder.Configuration); +builder.Services.AddHealthChecks(); builder.Services.AddSharedOpenApiDocumentation(); builder.Services.AddWolverineHttp(); diff --git a/src/Services/Webhooks/Webhooks.Api/appsettings.json b/src/Services/Webhooks/Webhooks.Api/appsettings.json index e89523d2..77284402 100644 --- a/src/Services/Webhooks/Webhooks.Api/appsettings.json +++ b/src/Services/Webhooks/Webhooks.Api/appsettings.json @@ -26,9 +26,5 @@ "LogLevel": { "Default": "Information" } - }, - "Redaction": { - "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", - "KeyId": 1001 } } diff --git a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/WebhooksDbContextDesignTimeFactory.cs b/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/WebhooksDbContextDesignTimeFactory.cs index 49e5b2ad..78b874dd 100644 --- a/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/WebhooksDbContextDesignTimeFactory.cs +++ b/src/Services/Webhooks/Webhooks.Infrastructure/Persistence/WebhooksDbContextDesignTimeFactory.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; -using SharedKernel.Infrastructure.Persistence; namespace Webhooks.Infrastructure.Persistence; @@ -15,11 +14,7 @@ public WebhooksDbContext CreateDbContext(string[] args) { DbContextOptionsBuilder optionsBuilder = new(); optionsBuilder.UseNpgsql( - DesignTimeConnectionStringResolver.Resolve( - "src/Services/Webhooks/Webhooks.Api", - "DefaultConnection", - args - ) + "Host=localhost;Database=webhooks_db;Username=postgres;Password=postgres" ); return new WebhooksDbContext(optionsBuilder.Options); diff --git a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandler.cs b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandler.cs index 9744020b..4aa4681a 100644 --- a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandler.cs +++ b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandler.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.Logging; using SharedKernel.Application.Errors; using SharedKernel.Domain.Exceptions; -using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.ExceptionHandling; @@ -57,9 +56,6 @@ CancellationToken cancellationToken IReadOnlyDictionary? metadata ) = Resolve(exception); - if (statusCode >= StatusCodes.Status409Conflict) - ConflictTelemetry.Record(exception, errorCode); - ProblemDetails problemDetails = new ProblemDetails { Status = statusCode, @@ -75,12 +71,19 @@ CancellationToken cancellationToken if (statusCode >= StatusCodes.Status500InternalServerError) { - _logger.UnhandledException(exception, statusCode, errorCode, context.TraceIdentifier); + _logger.LogError( + exception, + "Unhandled exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}", + statusCode, + errorCode, + context.TraceIdentifier + ); } else { - _logger.HandledApplicationException( + _logger.LogWarning( exception, + "Handled application exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}", statusCode, errorCode, context.TraceIdentifier diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/HostExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/HostExtensions.cs index 209a39ba..393d0490 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/HostExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/HostExtensions.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.Extensions; @@ -12,15 +11,6 @@ public static async Task MigrateDbAsync(this WebApplication app) { using AsyncServiceScope scope = app.Services.CreateAsyncScope(); TDbContext dbContext = scope.ServiceProvider.GetRequiredService(); - using StartupTelemetry.Scope telemetry = StartupTelemetry.StartRelationalMigration(); - try - { - await dbContext.Database.MigrateAsync(); - } - catch (Exception ex) - { - telemetry.Fail(ex); - throw; - } + await dbContext.Database.MigrateAsync(); } } diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/KeycloakAuthExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/KeycloakAuthExtensions.cs index 42186839..9e7fc35e 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/KeycloakAuthExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/KeycloakAuthExtensions.cs @@ -6,7 +6,6 @@ using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.Tokens; using SharedKernel.Application.Security; -using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.Extensions; @@ -80,25 +79,9 @@ private static void WrapTokenValidated(JwtBearerOptions options, bool requireTen await existingHandler(context); if (requireTenantClaim && !HasValidTenantClaim(context.Principal)) - { - AuthTelemetry.RecordMissingTenantClaim( - context.HttpContext, - JwtBearerDefaults.AuthenticationScheme - ); context.Fail($"Missing required {SharedAuthConstants.Claims.TenantId} claim."); - } - }, - OnAuthenticationFailed = async context => - { - if (existingEvents.OnAuthenticationFailed is not null) - await existingEvents.OnAuthenticationFailed(context); - - AuthTelemetry.RecordAuthenticationFailed( - context.HttpContext, - JwtBearerDefaults.AuthenticationScheme, - context.Exception - ); }, + OnAuthenticationFailed = existingEvents.OnAuthenticationFailed, OnChallenge = existingEvents.OnChallenge, OnForbidden = existingEvents.OnForbidden, OnMessageReceived = existingEvents.OnMessageReceived, diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs index 6659bf0c..423ab013 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs @@ -1,17 +1,10 @@ -using System.Diagnostics; -using System.Reflection; -using System.Runtime.InteropServices; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Npgsql; -using OpenTelemetry.Instrumentation.AspNetCore; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; -using SharedKernel.Application.Options; -using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.Extensions; @@ -24,238 +17,38 @@ public static IServiceCollection AddSharedObservability( string serviceName ) { - IConfigurationSection observabilitySection = configuration.GetSection( - ObservabilityOptions.SectionName - ); - if (observabilitySection.Exists()) - { - services.AddValidatedOptions( - configuration, - ObservabilityOptions.SectionName - ); - } - else - { - services.AddOptions().ValidateDataAnnotations().ValidateOnStart(); - } + string otlpEndpoint = configuration["Observability:Otlp:Endpoint"] ?? "http://alloy:4317"; - services.AddSharedLogRedaction(configuration); - - services.AddSingleton(); - services.Configure(options => - { - options.Delay = TimeSpan.FromSeconds(15); - options.Period = TimeSpan.FromMinutes(5); - }); - - Activity.DefaultIdFormat = ActivityIdFormat.W3C; - Activity.ForceDefaultIdFormat = true; - - ObservabilityOptions options = GetObservabilityOptions(configuration); - Dictionary resourceAttributes = BuildResourceAttributes( - serviceName, - environment - ); - bool enableConsoleExporter = IsConsoleExporterEnabled(options); - IReadOnlyList otlpEndpoints = GetEnabledOtlpEndpoints(options, environment); - - var openTelemetryBuilder = services + services .AddOpenTelemetry() - .ConfigureResource(resource => resource.AddAttributes(resourceAttributes)); - - openTelemetryBuilder.WithTracing(builder => - { - builder - .AddAspNetCoreInstrumentation(ConfigureAspNetCoreTracing) - .AddHttpClientInstrumentation() - .AddRedisInstrumentation() - .AddNpgsql() - .AddSource( - ObservabilityConventions.ActivitySourceName, - TelemetryThirdPartySources.Wolverine - ); - - ConfigureTracingExporters(builder, otlpEndpoints, enableConsoleExporter); - }); - - openTelemetryBuilder.WithMetrics(builder => - { - builder - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation() - .AddProcessInstrumentation() - .AddMeter( - ObservabilityConventions.MeterName, - ObservabilityConventions.HealthMeterName, - TelemetryMeterNames.AspNetCoreHosting, - TelemetryMeterNames.AspNetCoreServerKestrel, - TelemetryMeterNames.AspNetCoreConnections, - TelemetryMeterNames.AspNetCoreRouting, - TelemetryMeterNames.AspNetCoreDiagnostics, - TelemetryMeterNames.AspNetCoreRateLimiting, - TelemetryMeterNames.AspNetCoreAuthentication, - TelemetryMeterNames.AspNetCoreAuthorization, - TelemetryThirdPartySources.Wolverine - ) - .AddView( - TelemetryInstrumentNames.HttpServerRequestDuration, - new ExplicitBucketHistogramConfiguration - { - Boundaries = TelemetryHistogramBoundaries.HttpRequestDurationSeconds, - } - ) - .AddView( - TelemetryInstrumentNames.HttpClientRequestDuration, - new ExplicitBucketHistogramConfiguration - { - Boundaries = TelemetryHistogramBoundaries.HttpRequestDurationSeconds, - } - ) - .AddView( - TelemetryMetricNames.OutputCacheInvalidationDuration, - new ExplicitBucketHistogramConfiguration - { - Boundaries = TelemetryHistogramBoundaries.CacheOperationDurationMs, - } - ); - - ConfigureMetricExporters(builder, otlpEndpoints, enableConsoleExporter); - }); + .ConfigureResource(resource => + resource.AddService(serviceName: serviceName, serviceVersion: "1.0.0") + ) + .WithTracing(tracing => + { + tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddNpgsql() + .AddSource("Wolverine"); + + if (environment.IsDevelopment()) + tracing.AddConsoleExporter(); + + tracing.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)); + }) + .WithMetrics(metrics => + { + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddProcessInstrumentation() + .AddMeter("Wolverine"); + + metrics.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)); + }); return services; } - - internal static IReadOnlyList GetEnabledOtlpEndpoints( - ObservabilityOptions options, - IHostEnvironment environment - ) - { - List endpoints = []; - - if (IsAspireExporterEnabled(options, environment)) - { - string aspireEndpoint = string.IsNullOrWhiteSpace(options.Aspire.Endpoint) - ? TelemetryDefaults.AspireOtlpEndpoint - : options.Aspire.Endpoint; - endpoints.Add(aspireEndpoint); - } - - if ( - IsOtlpExporterEnabled(options, environment) - && !string.IsNullOrWhiteSpace(options.Otlp.Endpoint) - ) - { - endpoints.Add(options.Otlp.Endpoint); - } - - return endpoints.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - } - - internal static bool IsAspireExporterEnabled( - ObservabilityOptions options, - IHostEnvironment environment - ) => - options.Exporters.Aspire.Enabled - ?? (environment.IsDevelopment() && !IsRunningInContainer()); - - internal static bool IsOtlpExporterEnabled( - ObservabilityOptions options, - IHostEnvironment environment - ) => options.Exporters.Otlp.Enabled ?? IsRunningInContainer(); - - internal static bool IsConsoleExporterEnabled(ObservabilityOptions options) => - options.Exporters.Console.Enabled ?? false; - - internal static ObservabilityOptions GetObservabilityOptions(IConfiguration configuration) => - configuration.GetSection(ObservabilityOptions.SectionName).Get() - ?? new(); - - internal static Dictionary BuildResourceAttributes( - string serviceName, - IHostEnvironment environment - ) - { - AssemblyName? entryAssembly = Assembly.GetEntryAssembly()?.GetName(); - string machineName = Environment.MachineName; - int processId = Environment.ProcessId; - - return new Dictionary - { - [TelemetryResourceAttributeKeys.AssemblyName] = entryAssembly?.Name ?? serviceName, - [TelemetryResourceAttributeKeys.ServiceName] = serviceName, - [TelemetryResourceAttributeKeys.ServiceNamespace] = serviceName, - [TelemetryResourceAttributeKeys.ServiceVersion] = - entryAssembly?.Version?.ToString() ?? TelemetryDefaults.Unknown, - [TelemetryResourceAttributeKeys.ServiceInstanceId] = $"{machineName}-{processId}", - [TelemetryResourceAttributeKeys.DeploymentEnvironmentName] = - environment.EnvironmentName, - [TelemetryResourceAttributeKeys.HostName] = machineName, - [TelemetryResourceAttributeKeys.HostArchitecture] = - RuntimeInformation.OSArchitecture.ToString(), - [TelemetryResourceAttributeKeys.OsType] = GetOsType(), - [TelemetryResourceAttributeKeys.ProcessPid] = processId, - [TelemetryResourceAttributeKeys.ProcessRuntimeName] = ".NET", - [TelemetryResourceAttributeKeys.ProcessRuntimeVersion] = Environment.Version.ToString(), - }; - } - - internal static void ConfigureAspNetCoreTracing(AspNetCoreTraceInstrumentationOptions options) - { - options.RecordException = true; - options.Filter = httpContext => - !httpContext.Request.Path.StartsWithSegments(TelemetryPathPrefixes.Health); - options.EnrichWithHttpRequest = (activity, httpRequest) => - { - if (TelemetryApiSurfaceResolver.Resolve(httpRequest.Path) != TelemetrySurfaces.Rest) - return; - - string route = HttpRouteResolver.Resolve(httpRequest.HttpContext); - activity.DisplayName = $"{httpRequest.Method} {route}"; - activity.SetTag(TelemetryTagKeys.HttpRoute, route); - }; - } - - private static bool IsRunningInContainer() => - string.Equals( - Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), - "true", - StringComparison.OrdinalIgnoreCase - ); - - private static string GetOsType() => - OperatingSystem.IsWindows() ? "windows" - : OperatingSystem.IsLinux() ? "linux" - : OperatingSystem.IsMacOS() ? "darwin" - : TelemetryDefaults.Unknown; - - private static void ConfigureTracingExporters( - TracerProviderBuilder builder, - IReadOnlyList otlpEndpoints, - bool enableConsoleExporter - ) - { - foreach (string endpoint in otlpEndpoints) - { - builder.AddOtlpExporter(options => options.Endpoint = new Uri(endpoint)); - } - - if (enableConsoleExporter) - builder.AddConsoleExporter(); - } - - private static void ConfigureMetricExporters( - MeterProviderBuilder builder, - IReadOnlyList otlpEndpoints, - bool enableConsoleExporter - ) - { - foreach (string endpoint in otlpEndpoints) - { - builder.AddOtlpExporter(options => options.Endpoint = new Uri(endpoint)); - } - - if (enableConsoleExporter) - builder.AddConsoleExporter(); - } } diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/OutputCachingExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/OutputCachingExtensions.cs index 83af48f8..e26316dc 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/OutputCachingExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/OutputCachingExtensions.cs @@ -25,9 +25,10 @@ IConfiguration configuration options.InstanceName = RedisInstanceNames.OutputCache; }); } - - services.AddSingleton(); - services.AddOutputCache(); + else + { + services.AddOutputCache(); + } services.AddScoped(); @@ -64,12 +65,10 @@ IConfiguration configuration { options.AddPolicy( name, - builder => - builder - .AddPolicy() - .Expire(TimeSpan.FromSeconds(expirationSeconds)) - .Tag(name), - excludeDefaultPolicy: true + new TenantAwareOutputCachePolicy( + name, + TimeSpan.FromSeconds(expirationSeconds) + ) ); } }); diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/SerilogExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/SerilogExtensions.cs index d215550f..12f7f670 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/SerilogExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/SerilogExtensions.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.Hosting; using Serilog; -using SharedKernel.Infrastructure.Logging; namespace SharedKernel.Api.Extensions; @@ -15,7 +14,6 @@ public static IHostBuilder UseSharedSerilog(this IHostBuilder hostBuilder) .ReadFrom.Configuration(context.Configuration) .ReadFrom.Services(services) .Enrich.FromLogContext() - .Enrich.With() .Enrich.WithProperty("Application", context.HostingEnvironment.ApplicationName); } ); diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/SharedServiceRegistration.cs b/src/SharedKernel/SharedKernel.Api/Extensions/SharedServiceRegistration.cs index f7700a45..8ea4abff 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/SharedServiceRegistration.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/SharedServiceRegistration.cs @@ -1,18 +1,13 @@ using Asp.Versioning; -using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using SharedKernel.Api.Filters.Validation; using SharedKernel.Api.Security; -using SharedKernel.Application.Batch.Rules; using SharedKernel.Application.Context; using SharedKernel.Application.Options; using SharedKernel.Domain.Interfaces; -using SharedKernel.Infrastructure.Observability; -using SharedKernel.Infrastructure.Persistence; using SharedKernel.Infrastructure.Persistence.Auditing; using SharedKernel.Infrastructure.Persistence.SoftDelete; using SharedKernel.Infrastructure.Persistence.UnitOfWork; @@ -53,7 +48,6 @@ IConfiguration configuration // Auditing & Soft Delete services.AddScoped(); services.AddScoped(); - services.AddScoped(); // Context providers services.AddHttpContextAccessor(); @@ -61,10 +55,6 @@ IConfiguration configuration services.AddScoped(); services.AddSingleton(TimeProvider.System); - // Validation metrics (IValidationMetrics → ValidationTelemetry) - services.AddSingleton(); - services.AddTransient(typeof(FluentValidationBatchRule<>)); - // Exception handling & ProblemDetails (RFC 7807) services.AddSharedApiErrorHandling(); @@ -84,18 +74,4 @@ IConfiguration configuration return services; } - - /// - /// Registers MVC controllers with shared global filters (FluentValidation, etc.). - /// Use this instead of AddControllers() in every API host. - /// - public static IMvcBuilder AddSharedControllers(this IServiceCollection services) - { - services.AddScoped(); - - return services.AddControllers(options => - { - options.Filters.AddService(); - }); - } } diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs index 4f636d75..c3a0c7d9 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/WebApplicationPipelineExtensions.cs @@ -31,19 +31,10 @@ bool useOutputCaching { app.UseAuthorization(); - app.UseRequestContextPipeline(); - if (useOutputCaching) app.UseSharedOutputCaching(); app.MapSharedOpenApiEndpoint(); - app.MapSharedHealthChecks(); - - return app; - } - - public static WebApplication MapSharedHealthChecks(this WebApplication app) - { app.MapHealthChecks("/health").AllowAnonymous(); return app; diff --git a/src/SharedKernel/SharedKernel.Api/Filters/Validation/FluentValidationActionFilter.cs b/src/SharedKernel/SharedKernel.Api/Filters/Validation/FluentValidationActionFilter.cs index 511fcb3e..89af7ba8 100644 --- a/src/SharedKernel/SharedKernel.Api/Filters/Validation/FluentValidationActionFilter.cs +++ b/src/SharedKernel/SharedKernel.Api/Filters/Validation/FluentValidationActionFilter.cs @@ -1,8 +1,6 @@ using FluentValidation; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; -using SharedKernel.Application.Batch.Rules; -using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.Filters.Validation; @@ -16,15 +14,10 @@ namespace SharedKernel.Api.Filters.Validation; public sealed class FluentValidationActionFilter : IAsyncActionFilter { private readonly IServiceProvider _serviceProvider; - private readonly IValidationMetrics _metrics; - public FluentValidationActionFilter( - IServiceProvider serviceProvider, - IValidationMetrics metrics - ) + public FluentValidationActionFilter(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; - _metrics = metrics; } /// @@ -57,12 +50,6 @@ ActionExecutionDelegate next if (result.IsValid) continue; - string route = context.ActionDescriptor.AttributeRouteInfo?.Template is { } template - ? HttpRouteResolver.ReplaceVersionToken(template, context.RouteData.Values) - : context.HttpContext.Request.Path.Value ?? TelemetryDefaults.Unknown; - - _metrics.RecordFailure(route, argumentType, result.Errors); - foreach (FluentValidation.Results.ValidationFailure error in result.Errors) context.ModelState.AddModelError(error.PropertyName, error.ErrorMessage); } diff --git a/src/SharedKernel/SharedKernel.Api/OutputCaching/OutputCacheInvalidationService.cs b/src/SharedKernel/SharedKernel.Api/OutputCaching/OutputCacheInvalidationService.cs index 466c038a..6f1e390c 100644 --- a/src/SharedKernel/SharedKernel.Api/OutputCaching/OutputCacheInvalidationService.cs +++ b/src/SharedKernel/SharedKernel.Api/OutputCaching/OutputCacheInvalidationService.cs @@ -1,7 +1,5 @@ -using System.Diagnostics; using Microsoft.AspNetCore.OutputCaching; using Microsoft.Extensions.Logging; -using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.OutputCaching; @@ -29,15 +27,9 @@ public async Task EvictAsync( { foreach (string tag in tags.Distinct(StringComparer.Ordinal)) { - long startedAt = Stopwatch.GetTimestamp(); try { - using Activity? activity = CacheTelemetry.StartOutputCacheInvalidationActivity(tag); await _store.EvictByTagAsync(tag, cancellationToken); - CacheTelemetry.RecordOutputCacheInvalidation( - tag, - Stopwatch.GetElapsedTime(startedAt) - ); } catch (Exception ex) when (ex is not OperationCanceledException) { diff --git a/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs b/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs index 5d8f9124..f45eadd5 100644 --- a/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs +++ b/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.OutputCaching; using SharedKernel.Application.Security; -using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.OutputCaching; @@ -39,7 +38,6 @@ CancellationToken cancellationToken context.EnableOutputCaching = true; context.AllowCacheLookup = true; context.AllowCacheStorage = true; - CacheTelemetry.ConfigureRequest(context); string tenantId = context.HttpContext.User.FindFirstValue(SharedAuthConstants.Claims.TenantId) @@ -61,19 +59,10 @@ CancellationToken cancellationToken public ValueTask ServeFromCacheAsync( OutputCacheContext context, CancellationToken cancellationToken - ) - { - context.HttpContext.Response.Headers.Age = "0"; - CacheTelemetry.RecordCacheHit(context); - return ValueTask.CompletedTask; - } + ) => ValueTask.CompletedTask; public ValueTask ServeResponseAsync( OutputCacheContext context, CancellationToken cancellationToken - ) - { - CacheTelemetry.RecordResponseOutcome(context); - return ValueTask.CompletedTask; - } + ) => ValueTask.CompletedTask; } diff --git a/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj b/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj index 651530b1..ff538327 100644 --- a/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj +++ b/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj @@ -1,10 +1,8 @@ - - @@ -20,17 +18,12 @@ - - - - - @@ -39,7 +32,6 @@ - diff --git a/src/SharedKernel/SharedKernel.Application/Batch/Rules/FluentValidationBatchRule.cs b/src/SharedKernel/SharedKernel.Application/Batch/Rules/FluentValidationBatchRule.cs index d20e42cb..0063c700 100644 --- a/src/SharedKernel/SharedKernel.Application/Batch/Rules/FluentValidationBatchRule.cs +++ b/src/SharedKernel/SharedKernel.Application/Batch/Rules/FluentValidationBatchRule.cs @@ -1,14 +1,13 @@ using FluentValidation; -using FluentValidation.Results; using SharedKernel.Domain.Entities.Contracts; namespace SharedKernel.Application.Batch.Rules; -public sealed class FluentValidationBatchRule( - IValidator validator, - IValidationMetrics metrics -) : IBatchRule +public sealed class FluentValidationBatchRule(IValidator validator) + : IBatchRule { + private readonly IValidator _validator = validator; + public async Task ApplyAsync(BatchFailureContext context, CancellationToken ct) { for (int i = 0; i < context.Items.Count; i++) @@ -16,7 +15,8 @@ public async Task ApplyAsync(BatchFailureContext context, CancellationTok if (context.IsFailed(i)) continue; - ValidationResult validationResult = await validator.ValidateAsync(context.Items[i], ct); + FluentValidation.Results.ValidationResult validationResult = + await _validator.ValidateAsync(context.Items[i], ct); if (!validationResult.IsValid) { Guid? id = context.Items[i] is IHasId hasId ? hasId.Id : null; @@ -25,12 +25,6 @@ public async Task ApplyAsync(BatchFailureContext context, CancellationTok id, validationResult.Errors.Select(error => error.ErrorMessage).ToList() ); - - metrics.RecordFailure( - $"batch/{typeof(TItem).Name}", - typeof(TItem), - validationResult.Errors - ); } } } diff --git a/src/SharedKernel/SharedKernel.Application/Middleware/ErrorOrValidationMiddleware.cs b/src/SharedKernel/SharedKernel.Application/Middleware/ErrorOrValidationMiddleware.cs new file mode 100644 index 00000000..2eb99a0c --- /dev/null +++ b/src/SharedKernel/SharedKernel.Application/Middleware/ErrorOrValidationMiddleware.cs @@ -0,0 +1,53 @@ +using ErrorOr; +using FluentValidation; +using SharedKernel.Application.Errors; +using Wolverine; + +namespace SharedKernel.Application.Middleware; + +/// +/// Wolverine handler middleware that validates incoming messages using FluentValidation +/// and short-circuits with errors instead of throwing exceptions. +/// Applied only to handlers whose return type is ErrorOr<T>. +/// +public static class ErrorOrValidationMiddleware +{ + /// + /// Runs FluentValidation before the handler executes. If validation fails, + /// returns with validation errors + /// so the handler is never invoked. + /// + public static async Task<(HandlerContinuation, ErrorOr)> BeforeAsync< + TMessage, + TResponse + >(TMessage message, IValidator? validator = null, CancellationToken ct = default) + { + if (validator is null) + return (HandlerContinuation.Continue, default!); + + FluentValidation.Results.ValidationResult validationResult = await validator.ValidateAsync( + message, + ct + ); + + if (validationResult.IsValid) + return (HandlerContinuation.Continue, default!); + + List errors = validationResult + .Errors.Select(e => + { + Dictionary metadata = new() { ["propertyName"] = e.PropertyName }; + if (e.AttemptedValue is not null) + metadata["attemptedValue"] = e.AttemptedValue; + + return Error.Validation( + code: ErrorCatalog.General.ValidationFailed, + description: e.ErrorMessage, + metadata: metadata + ); + }) + .ToList(); + + return (HandlerContinuation.Stop, errors); + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs index 8b502512..9749c9b9 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs +++ b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs @@ -29,32 +29,41 @@ public Task ProcessAsync( CancellationToken cancellationToken ) { - SoftDeleteOperationContext ctx = new( + HashSet visited = new(ReferenceEqualityComparer.Instance); + return SoftDeleteWithRulesAsync( dbContext, + entry, + entity, now, actor, softDeleteCascadeRules, - new HashSet(ReferenceEqualityComparer.Instance) + visited, + cancellationToken ); - return SoftDeleteWithRulesAsync(ctx, entry, entity, cancellationToken); } private async Task SoftDeleteWithRulesAsync( - SoftDeleteOperationContext ctx, + DbContext dbContext, EntityEntry entry, IAuditableTenantEntity entity, + DateTime now, + Guid actor, + IReadOnlyCollection softDeleteCascadeRules, + HashSet visited, CancellationToken cancellationToken ) { - if (!ctx.Visited.Add(entity)) + if (!visited.Add(entity)) return; - _stateManager.MarkSoftDeleted(entry, entity, ctx.Now, ctx.Actor); + _stateManager.MarkSoftDeleted(entry, entity, now, actor); - foreach (ISoftDeleteCascadeRule rule in ctx.Rules.Where(r => r.CanHandle(entity))) + foreach ( + ISoftDeleteCascadeRule rule in softDeleteCascadeRules.Where(r => r.CanHandle(entity)) + ) { IReadOnlyCollection dependents = await rule.GetDependentsAsync( - ctx.DbContext, + dbContext, entity, cancellationToken ); @@ -63,17 +72,18 @@ CancellationToken cancellationToken if (dependent.IsDeleted || dependent.TenantId != entity.TenantId) continue; - EntityEntry dependentEntry = ctx.DbContext.Entry(dependent); - await SoftDeleteWithRulesAsync(ctx, dependentEntry, dependent, cancellationToken); + EntityEntry dependentEntry = dbContext.Entry(dependent); + await SoftDeleteWithRulesAsync( + dbContext, + dependentEntry, + dependent, + now, + actor, + softDeleteCascadeRules, + visited, + cancellationToken + ); } } } - - private sealed record SoftDeleteOperationContext( - DbContext DbContext, - DateTime Now, - Guid Actor, - IReadOnlyCollection Rules, - HashSet Visited - ); } diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContext.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContext.cs index 0193117a..8f751a48 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContext.cs +++ b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContext.cs @@ -1,6 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; +using SharedKernel.Application.Context; using SharedKernel.Domain.Entities.Contracts; +using SharedKernel.Infrastructure.Persistence.Auditing; using SharedKernel.Infrastructure.Persistence.EntityNormalization; using SharedKernel.Infrastructure.Persistence.SoftDelete; @@ -12,22 +14,35 @@ namespace SharedKernel.Infrastructure.Persistence; /// public abstract class TenantAuditableDbContext : DbContext { - private readonly TenantAuditableDbContextDependencies _deps; - private readonly IReadOnlyList _softDeleteCascadeRules; + private readonly ITenantProvider _tenantProvider; + private readonly IActorProvider _actorProvider; + private readonly TimeProvider _timeProvider; + private readonly IReadOnlyCollection _softDeleteCascadeRules; + private readonly IAuditableEntityStateManager _entityStateManager; + private readonly ISoftDeleteProcessor _softDeleteProcessor; private readonly IEntityNormalizationService? _entityNormalizationService; - protected Guid CurrentTenantId => _deps.TenantProvider.TenantId; - protected bool HasTenant => _deps.TenantProvider.HasTenant; + protected Guid CurrentTenantId => _tenantProvider.TenantId; + protected bool HasTenant => _tenantProvider.HasTenant; protected TenantAuditableDbContext( DbContextOptions options, - TenantAuditableDbContextDependencies deps, + ITenantProvider tenantProvider, + IActorProvider actorProvider, + TimeProvider timeProvider, + IEnumerable softDeleteCascadeRules, + IAuditableEntityStateManager entityStateManager, + ISoftDeleteProcessor softDeleteProcessor, IEntityNormalizationService? entityNormalizationService = null ) : base(options) { - _deps = deps; - _softDeleteCascadeRules = deps.SoftDeleteCascadeRules.ToList(); + _tenantProvider = tenantProvider; + _actorProvider = actorProvider; + _timeProvider = timeProvider; + _softDeleteCascadeRules = softDeleteCascadeRules.ToList(); + _entityStateManager = entityStateManager; + _softDeleteProcessor = softDeleteProcessor; _entityNormalizationService = entityNormalizationService; } @@ -49,8 +64,8 @@ public override async Task SaveChangesAsync( private async Task ApplyEntityAuditingAsync(CancellationToken cancellationToken) { - DateTime now = _deps.TimeProvider.GetUtcNow().UtcDateTime; - Guid actor = _deps.ActorProvider.ActorId; + DateTime now = _timeProvider.GetUtcNow().UtcDateTime; + Guid actor = _actorProvider.ActorId; foreach ( EntityEntry entry in ChangeTracker @@ -64,7 +79,7 @@ EntityEntry entry in ChangeTracker { case EntityState.Added: _entityNormalizationService?.Normalize(entity); - _deps.EntityStateManager.StampAdded( + _entityStateManager.StampAdded( entry, entity, now, @@ -75,10 +90,10 @@ EntityEntry entry in ChangeTracker break; case EntityState.Modified: _entityNormalizationService?.Normalize(entity); - _deps.EntityStateManager.StampModified(entity, now, actor); + _entityStateManager.StampModified(entity, now, actor); break; case EntityState.Deleted: - await _deps.SoftDeleteProcessor.ProcessAsync( + await _softDeleteProcessor.ProcessAsync( this, entry, entity, diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Repositories/RepositoryBase.cs b/src/SharedKernel/SharedKernel.Infrastructure/Repositories/RepositoryBase.cs index c3849687..ed3c49e2 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/Repositories/RepositoryBase.cs +++ b/src/SharedKernel/SharedKernel.Infrastructure/Repositories/RepositoryBase.cs @@ -86,6 +86,8 @@ public virtual async Task> GetPagedAsync( return new PagedResponse([], 0, pageNumber, pageSize); } + // Override write methods — do NOT call SaveChangesAsync, that is UoW responsibility. + // Return 0 (no rows persisted yet — UoW will commit later). /// Tracks for insertion without flushing to the database. public override Task AddAsync(T entity, CancellationToken ct = default) { diff --git a/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj b/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj index 11cf49ce..6f7f1198 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj +++ b/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj @@ -1,7 +1,6 @@ - @@ -12,18 +11,12 @@ enable - - - - - - diff --git a/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs b/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs index 07a4ddb1..9f6e86a6 100644 --- a/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs +++ b/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs @@ -21,16 +21,14 @@ public static WolverineOptions UseSharedRabbitMq( IConfiguration configuration ) { - string connectionString = ResolveConnectionString(configuration); + string connectionString = + configuration.GetConnectionString("RabbitMQ") ?? BuildFromHostName(configuration); opts.UseRabbitMq(new Uri(connectionString)).AutoProvision().EnableWolverineControlQueues(); return opts; } - internal static string ResolveConnectionString(IConfiguration configuration) => - configuration.GetConnectionString("RabbitMQ") ?? BuildFromHostName(configuration); - private static string BuildFromHostName(IConfiguration configuration) { string? hostName = configuration["RabbitMQ:HostName"]; diff --git a/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj b/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj index 108382f6..539302c8 100644 --- a/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj +++ b/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj @@ -4,20 +4,12 @@ - - - - net10.0 enable enable - - - - From 853e9edd533c660bc5ed9d1121b39bd3b3980922 Mon Sep 17 00:00:00 2001 From: Tadeas Zribko Date: Tue, 31 Mar 2026 12:40:57 +0200 Subject: [PATCH 06/14] feat: Enhance efficiency and architectural consistency in microservices --- TODO-Architecture.md | 113 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/TODO-Architecture.md b/TODO-Architecture.md index ea7a3839..265bfe8f 100644 --- a/TODO-Architecture.md +++ b/TODO-Architecture.md @@ -28,6 +28,119 @@ All 7 microservices extracted from the monolith and running independently. ### Gateway — Demo / Example APIs - Expose monolith-style demo endpoints (`IdempotentController`, `PatchController`, `SseController` under `src/APITemplate.Api/`) via a dedicated Examples service or Gateway host, and register routes in YARP. +### Efficiency — Must Fix (pre-production) + +- **RabbitMQ health check connection churn** (`SharedKernel.Messaging/HealthChecks/RabbitMqHealthCheck.cs`): + Every health check poll (30s × 7 services) creates a brand-new TCP+AMQP connection and immediately disposes it. Reuse the existing Wolverine-managed connection or cache a single health-check connection. + +- **Webhook delivery is sequential per subscriber** (`Webhooks.Infrastructure/Delivery/WebhookDeliveryService.cs:52-56`): + `foreach` + `await` for each subscriber = N sequential HTTP round-trips. Each delivery log is also persisted individually (N separate DB writes). Use `Parallel.ForEachAsync` with bounded concurrency and batch `SaveChangesAsync` after all deliveries. + +- **No PostgreSQL connection pooling configuration** (all service `Program.cs` + `docker-compose.microservices.yml`): + 7 services + Wolverine persistence = up to 700 simultaneous connections (default Npgsql pool = 100). Add `Maximum Pool Size=25;Minimum Pool Size=2;Connection Idle Lifetime=60` to connection strings. + +- **Docker builds copy entire repository** (all `Dockerfile`s): + `COPY . .` invalidates cache on any file change in any service. Split into csproj-only COPY for restore layer, then full copy for build. Use `.dockerignore` to exclude unrelated services. + +### Efficiency — Should Fix + +- **Gateway blocks startup until ALL 7 services healthy** (`docker-compose.microservices.yml:118-133`): + YARP handles upstream failures gracefully. Remove `depends_on: service_healthy` conditions — let Gateway start immediately and route to healthy backends dynamically. + +- **FileStorage missing TenantId index** (`FileStorage.Infrastructure/Persistence/Configurations/StoredFileConfiguration.cs`): + No indexes beyond PK. Global query filter on `TenantId` causes full table scans. All other services have `TenantId` indexes — FileStorage is the only one missing it. + +- **`MigrateDbAsync` blocks startup** (`SharedKernel.Api/Extensions/HostExtensions.cs`): + 7 services run migrations concurrently against shared PostgreSQL → lock contention. Consider advisory lock or sequential migration orchestration. + +- **Sequential saga completion publishes** (`ProductCatalog.Application/EventHandlers/TenantDeactivatedEventHandler.cs:53-58`): + Two independent `bus.PublishAsync` calls awaited sequentially. Use Wolverine's `OutgoingMessages` cascading pattern (already used elsewhere via `CacheInvalidationCascades`). + +- **ChangeTracker `.ToList()` on every SaveChanges** (`SharedKernel.Infrastructure/Persistence/TenantAuditableDbContext.cs:70-76`): + Materializes all change-tracked entries on every `SaveChangesAsync` (hot path). Needed because loop can modify collection during soft-delete, but could split into two passes: streaming for non-Delete, `.ToList()` only for Delete. + +- **`FluentValidationActionFilter` uses reflection per request** (`SharedKernel.Api/Filters/Validation/FluentValidationActionFilter.cs:38`): + `MakeGenericType` + DI lookup on every request, for every action argument. Cache resolved validators in `ConcurrentDictionary`. + +- **`CacheInvalidationCascades.None` returns shared mutable instance** (`SharedKernel.Application/Common/Events/CacheInvalidationCascades.cs:12-13`): + `OutgoingMessages` inherits `List` — if any caller accidentally adds to the shared `None` singleton, it corrupts all consumers. Return `new OutgoingMessages()` each time or use a read-only wrapper. + +### DRY — Service Registration + +- **Split `AddSharedInfrastructure`** (`SharedKernel.Api/Extensions/SharedServiceRegistration.cs`): + Extract non-generic `AddSharedCoreServices()` (TimeProvider, HttpContextAccessor, context providers, error handling, versioning) so Webhooks/Notifications can call it without needing tenant-aware DbContext. The generic `AddSharedInfrastructure()` then calls `AddSharedCoreServices()` plus UoW/audit/soft-delete. + +- **Move `DbContext` base-type registration into `AddSharedInfrastructure`**: + `services.AddScoped(sp => sp.GetRequiredService())` is copy-pasted in 5 services (Identity, ProductCatalog, Reviews, FileStorage, BackgroundJobs). The generic type parameter is already available in `AddSharedInfrastructure`. + +- **Move `IRolePermissionMap` registration into `AddSharedAuthorization`**: + `AddSingleton()` is duplicated in 4 services. Register it automatically when `enablePermissionPolicies: true`. + +- **Automate `HasQueryFilter` for `IAuditableTenantEntity`** in `TenantAuditableDbContext.OnModelCreating`: + The identical filter expression `(!HasTenant || e.TenantId == CurrentTenantId) && !e.IsDeleted` is copy-pasted 8× across 4 DbContexts. Scan for all entities implementing `IAuditableTenantEntity` and apply automatically. Subclasses opt out for special cases only. + +- **Wolverine bootstrap helper** (`SharedKernel.Messaging`): + Add `opts.ApplySharedWolverineDefaults(connectionString)` that bundles conventions + retry + PostgreSQL persistence + EF transactions. 5 services repeat the same 4-line preamble. + +### DRY — Code Quality + +- **Use existing `TenantAuditableDbContextDependencies` parameter object**: + The record was created but never used. All 4 derived DbContexts manually forward 7 constructor parameters to `base(...)`. Switch constructors to accept the single parameter object. + +- **Shared `DesignTimeDbContextFactoryBase`** with NullObject implementations: + 4 identical DesignTime factories pass `null!` for `tenantProvider`, `actorProvider`, `entityStateManager`, `softDeleteProcessor`. Runtime NRE risk if any EF tooling path triggers `SaveChangesAsync`. + +- **Collapse webhook event handlers into a generic handler** (`Webhooks.Application/Features/Delivery/EventHandlers/`): + 4 handlers (`ProductCreated`, `ProductDeleted`, `ReviewCreated`, `CategoryDeleted`) are identical: log → serialize → deliver. Extract common `ITenantEvent` interface and one generic handler. + +- **Saga `NotFound` helper** (`TenantDeactivationSaga`, `ProductDeletionSaga`): + 7 identical `NotFound` methods across 2 sagas. Extract shared helper or Wolverine convention. + +- **Webhooks resilience pipeline key** (`Webhooks.Api/Program.cs:53`): + Raw string `"outgoing-webhook-retry"` — other services use typed constants. Add to a constants class. + +### Architectural Consistency + +- **Webhooks bypasses UnitOfWork / tenant auditing** (`Webhooks.Infrastructure/`): + Repositories call `_dbContext.SaveChangesAsync()` directly instead of `IUnitOfWork.CommitAsync()`. Entities implement `IAuditableTenantEntity` but use plain `DbContext` → no audit stamping, no query filters, no soft-delete processing. Manual tenant filtering in queries. Risk: deleted webhook subscriptions could be served. + +- **BackgroundJobs registers unused shared infrastructure** (`BackgroundJobs.Api/Program.cs:56`): + Calls `AddSharedInfrastructure` but `BackgroundJobsDbContext` extends raw `DbContext`, not `TenantAuditableDbContext`. UoW, auditing, and soft-delete services are registered but never invoked. + +- **Notifications skips API versioning** (`Notifications.Api/Program.cs`): + Does not call `AddSharedInfrastructure` → misses API versioning registration that all other services get. Calls `AddSharedApiErrorHandling()` separately. + +- **Redundant queue interface layer in Notifications Domain**: + `IQueue` and `IQueueReader` in `Notifications.Domain.Interfaces` are empty marker interfaces re-exporting SharedKernel interfaces. `IEmailQueue`/`IEmailQueueReader` could extend SharedKernel directly (like `BackgroundJobs.Application.Common.IJobQueue` already does). + +- **Reviews `ProductProjection` inconsistency**: + Uses `IsActive` flag instead of `IsDeleted` + `AuditInfo` pattern used everywhere else. Semantically different from the rest of the system. + +### Testing + +- **SQLite test setup boilerplate** (`tests/Identity.Tests/`, `tests/ProductCatalog.Tests/`): + Identical SQLite connection + DbContext + `EnsureCreated()` + `IDisposable` teardown copy-pasted across test classes. Add `SqliteTestDbContextFactory` to `Tests.Common`. + +- **Per-service `TestDbContext` duplication** (`tests/Identity.Tests/TestDbContext.cs`, `tests/ProductCatalog.Tests/TestDbContext.cs`, `tests/Reviews.Tests/TestDbContext.cs`): + Identical `OwnsOne(Audit)` + key convention per entity. Add `TestModelBuilderExtensions.ConfigureTestAuditableEntity()` to `Tests.Common`. + +### Architectural Decision Review + +> **Question to evaluate:** Is microservices the right architecture for this project's scale? +> +> **Evidence against:** +> - SharedKernel = 6717 LOC, all services combined = 3537 LOC (without migrations). Shared code is 1.9× larger than business code. +> - 7 services × 4 layers = 28 projects + 5 SharedKernel + 1 Contracts + 1 Gateway = 35 projects (was 5 in monolith — 7× increase). +> - All services are structurally identical (same middleware, same auth, same persistence patterns). +> - `RabbitMqTopology` is a static map in SharedKernel — changing a queue name requires coordinated deployment. +> - Saga timeouts accept partial completion silently (`MarkCompleted()` on timeout) — was a single DB transaction in the monolith. +> - Local dev requires 16+ containers vs 8 for monolith. +> - No documented drivers (independent scaling, deployment cadence, team boundaries, technology heterogeneity). +> +> **Alternative considered:** Modular monolith — same boundary enforcement (separate assemblies per bounded context, explicit integration event contracts, Wolverine as in-process bus) at a fraction of operational cost. Extract individual services later when concrete production metrics justify it. +> +> **Decision:** [TO BE EVALUATED] + ### Optional polish - **Output cache:** ProductCatalog, Identity, Reviews, and FileStorage use `AddSharedOutputCaching` + `[OutputCache]` + invalidation. BackgroundJobs still runs with `useOutputCaching: false` (it has a GET on `JobsController`); enable caching there only if you want read responses cached like the other APIs. From 66f00a18a047e9bd76588728645776282064adaa Mon Sep 17 00:00:00 2001 From: Tadeas Zribko Date: Tue, 31 Mar 2026 12:54:22 +0200 Subject: [PATCH 07/14] feat: Add messaging project reference and enhance RabbitMQ connection string resolution --- src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj | 4 ++++ .../SharedKernel.Infrastructure.csproj | 6 ++++++ .../Conventions/RabbitMqConventionExtensions.cs | 6 ++++-- .../SharedKernel.Messaging/SharedKernel.Messaging.csproj | 4 ++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj b/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj index ff538327..1ce17fc2 100644 --- a/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj +++ b/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj @@ -3,6 +3,7 @@ + @@ -18,12 +19,15 @@ + + + diff --git a/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj b/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj index 6f7f1198..9e31f350 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj +++ b/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj @@ -11,12 +11,18 @@ enable + + + + + + diff --git a/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs b/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs index 9f6e86a6..6d51c443 100644 --- a/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs +++ b/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs @@ -21,14 +21,16 @@ public static WolverineOptions UseSharedRabbitMq( IConfiguration configuration ) { - string connectionString = - configuration.GetConnectionString("RabbitMQ") ?? BuildFromHostName(configuration); + string connectionString = ResolveConnectionString(configuration); opts.UseRabbitMq(new Uri(connectionString)).AutoProvision().EnableWolverineControlQueues(); return opts; } + public static string ResolveConnectionString(IConfiguration configuration) => + configuration.GetConnectionString("RabbitMQ") ?? BuildFromHostName(configuration); + private static string BuildFromHostName(IConfiguration configuration) { string? hostName = configuration["RabbitMQ:HostName"]; diff --git a/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj b/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj index 539302c8..63a07483 100644 --- a/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj +++ b/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj @@ -10,6 +10,10 @@ enable + + + + From ac7abf9b450f892a514e5a5c66df215a83e5f109 Mon Sep 17 00:00:00 2001 From: Tadeas Zribko Date: Tue, 31 Mar 2026 13:15:16 +0200 Subject: [PATCH 08/14] feat: Enhance logging by adding personal data attributes and simplifying redaction options configuration --- .../ApiExceptionHandlerLogs.cs | 4 +-- .../Extensions/LoggingRedactionExtensions.cs | 25 ++++--------------- .../HealthCheckMetricsPublisher.cs | 7 +++++- 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs index 75336baf..56f6992f 100644 --- a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs +++ b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs @@ -15,7 +15,7 @@ public static partial void UnhandledException( Exception exception, int statusCode, [SensitiveData] string errorCode, - string traceId + [PersonalData] string traceId ); [LoggerMessage( @@ -28,6 +28,6 @@ public static partial void HandledApplicationException( Exception exception, int statusCode, [SensitiveData] string errorCode, - string traceId + [PersonalData] string traceId ); } diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/LoggingRedactionExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/LoggingRedactionExtensions.cs index 441dc76a..750a8f0d 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/LoggingRedactionExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/LoggingRedactionExtensions.cs @@ -1,4 +1,3 @@ -using System.ComponentModel.DataAnnotations; using Microsoft.Extensions.Compliance.Classification; using Microsoft.Extensions.Compliance.Redaction; using Microsoft.Extensions.Configuration; @@ -16,27 +15,13 @@ public static IServiceCollection AddSharedLogRedaction( IConfiguration configuration ) { - IConfigurationSection redactionSection = configuration.GetSection( - RedactionOptions.SectionName - ); - if (redactionSection.Exists()) - { - services.AddValidatedOptions( - configuration, - RedactionOptions.SectionName - ); - } - else - { - services.AddOptions().ValidateDataAnnotations().ValidateOnStart(); - } + services + .AddOptions() + .Bind(configuration.GetSection(RedactionOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); RedactionOptions redactionOptions = GetRedactionOptions(configuration); - Validator.ValidateObject( - redactionOptions, - new ValidationContext(redactionOptions), - validateAllProperties: true - ); string hmacKey = RedactionConfiguration.ResolveHmacKey( redactionOptions, diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs index 4c1a078b..532c171a 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs @@ -30,7 +30,12 @@ public Task PublishAsync(HealthReport report, CancellationToken cancellationToke return Task.CompletedTask; } - internal static IReadOnlyDictionary SnapshotStatuses() => Statuses; + internal static IReadOnlyDictionary SnapshotStatuses() => + Statuses.ToDictionary( + entry => entry.Key, + entry => entry.Value, + StringComparer.OrdinalIgnoreCase + ); private static IEnumerable> ObserveStatuses() { From 47da703812a3aee7a6ad6c3e0f7cf597adaf3234 Mon Sep 17 00:00:00 2001 From: Tadeas Zribko Date: Tue, 31 Mar 2026 13:25:21 +0200 Subject: [PATCH 09/14] feat: Implement health probe interface for MongoDB and enhance observability options --- .../Persistence/MongoDbContext.cs | 7 ++- .../Extensions/ObservabilityExtensions.cs | 54 +++++++++++++++++++ .../SharedKernel.Api/SharedKernel.Api.csproj | 4 ++ .../SharedKernel.Infrastructure.csproj | 4 ++ .../CategoryBatchCommandHandlerTests.cs | 34 ++++++------ .../ProductBatchCommandHandlerTests.cs | 15 ++---- 6 files changed, 86 insertions(+), 32 deletions(-) diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs index cecb2d35..50044774 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs @@ -6,11 +6,16 @@ namespace ProductCatalog.Infrastructure.Persistence; +public interface IMongoDbHealthProbe +{ + Task PingAsync(CancellationToken cancellationToken = default); +} + /// /// Thin wrapper around the MongoDB driver that configures the client with diagnostic /// activity tracing and exposes typed collection accessors for domain document types. /// -public sealed class MongoDbContext +public sealed class MongoDbContext : IMongoDbHealthProbe { private readonly IMongoDatabase _database; diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs index 423ab013..a73a5a32 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs @@ -5,6 +5,8 @@ using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; +using SharedKernel.Application.Options; +using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.Extensions; @@ -51,4 +53,56 @@ string serviceName return services; } + + internal static ObservabilityOptions GetObservabilityOptions(IConfiguration configuration) => + configuration.GetSection(ObservabilityOptions.SectionName).Get() + ?? new ObservabilityOptions(); + + internal static IReadOnlyList GetEnabledOtlpEndpoints( + ObservabilityOptions options, + IHostEnvironment environment + ) + { + List endpoints = []; + + bool aspireEnabled = options.Exporters.Aspire.Enabled ?? environment.IsDevelopment(); + if (aspireEnabled) + { + endpoints.Add( + string.IsNullOrWhiteSpace(options.Aspire.Endpoint) + ? TelemetryDefaults.AspireOtlpEndpoint + : options.Aspire.Endpoint + ); + } + + bool otlpEnabled = options.Exporters.Otlp.Enabled ?? !environment.IsDevelopment(); + if (otlpEnabled && !string.IsNullOrWhiteSpace(options.Otlp.Endpoint)) + { + endpoints.Add(options.Otlp.Endpoint); + } + + return endpoints.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } + + internal static Dictionary BuildResourceAttributes( + string serviceName, + IHostEnvironment environment + ) => + new(StringComparer.Ordinal) + { + [TelemetryResourceAttributeKeys.ServiceName] = serviceName, + [TelemetryResourceAttributeKeys.ServiceVersion] = "1.0.0", + [TelemetryResourceAttributeKeys.ServiceInstanceId] = Environment.MachineName, + [TelemetryResourceAttributeKeys.HostName] = Environment.MachineName, + [TelemetryResourceAttributeKeys.HostArchitecture] = + System.Runtime.InteropServices.RuntimeInformation.OSArchitecture.ToString(), + [TelemetryResourceAttributeKeys.OsType] = System + .Runtime + .InteropServices + .RuntimeInformation + .OSDescription, + [TelemetryResourceAttributeKeys.ProcessRuntimeVersion] = Environment.Version.ToString(), + [TelemetryResourceAttributeKeys.DeploymentEnvironmentName] = + environment.EnvironmentName, + }; } diff --git a/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj b/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj index 1ce17fc2..030b7b17 100644 --- a/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj +++ b/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj @@ -12,6 +12,10 @@ enable + + + + diff --git a/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj b/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj index 9e31f350..c642c00e 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj +++ b/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj @@ -11,6 +11,10 @@ enable + + + + diff --git a/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs b/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs index 0f21685d..93883bd6 100644 --- a/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs +++ b/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs @@ -5,7 +5,6 @@ using ProductCatalog.Application.Features.Category.DTOs; using ProductCatalog.Application.Features.Category.Validation; using ProductCatalog.Domain.Interfaces; -using SharedKernel.Application.Batch.Rules; using SharedKernel.Domain.Interfaces; using Shouldly; using Xunit; @@ -40,7 +39,7 @@ public async Task CreateHandleAsync_WhenValidationFails_ReturnsBatchFailureAndSk command, _repositoryMock.Object, _unitOfWorkMock.Object, - CreateBatchRule(new CreateCategoryRequestValidator()), + new CreateCategoryRequestValidator(), CancellationToken.None ); @@ -83,7 +82,7 @@ public async Task CreateHandleAsync_WhenItemsAreValid_PersistsAllCategories() command, _repositoryMock.Object, _unitOfWorkMock.Object, - CreateBatchRule(new CreateCategoryRequestValidator()), + new CreateCategoryRequestValidator(), CancellationToken.None ); @@ -95,7 +94,7 @@ public async Task CreateHandleAsync_WhenItemsAreValid_PersistsAllCategories() } [Fact] - public async Task LoadAsync_WhenCategoryIsMissing_ReturnsStopWithoutLookup() + public async Task HandleAsync_WhenCategoryIsMissing_ReturnsBatchFailure() { Guid missingId = Guid.NewGuid(); UpdateCategoriesCommand command = new( @@ -111,15 +110,22 @@ public async Task LoadAsync_WhenCategoryIsMissing_ReturnsStopWithoutLookup() ) .ReturnsAsync([]); - var (continuation, lookup, _) = await UpdateCategoriesCommandHandler.LoadAsync( + var (result, _) = await UpdateCategoriesCommandHandler.HandleAsync( command, _repositoryMock.Object, - CreateBatchRule(new UpdateCategoryItemValidator()), + _unitOfWorkMock.Object, + new UpdateCategoryItemValidator(), CancellationToken.None ); - continuation.ShouldBe(Wolverine.HandlerContinuation.Stop); - lookup.ShouldBeNull(); + result.IsError.ShouldBeFalse(); + result.Value.SuccessCount.ShouldBe(0); + result.Value.FailureCount.ShouldBe(1); + result + .Value.Failures[0] + .Errors.ShouldContain( + string.Format(ErrorCatalog.Categories.NotFoundMessage, missingId) + ); } [Fact] @@ -149,11 +155,9 @@ public async Task HandleAsync_WhenLookupContainsEntities_UpdatesEachCategory() var (result, _) = await UpdateCategoriesCommandHandler.HandleAsync( command, - new SharedKernel.Application.Batch.EntityLookup( - new Dictionary { [firstId] = first, [secondId] = second } - ), _repositoryMock.Object, _unitOfWorkMock.Object, + new UpdateCategoryItemValidator(), CancellationToken.None ); @@ -168,12 +172,4 @@ public async Task HandleAsync_WhenLookupContainsEntities_UpdatesEachCategory() Times.Exactly(2) ); } - - private static FluentValidationBatchRule CreateBatchRule( - FluentValidation.IValidator validator - ) - { - Mock metrics = new(); - return new FluentValidationBatchRule(validator, metrics.Object); - } } diff --git a/tests/ProductCatalog.Tests/Features/Product/Commands/ProductBatchCommandHandlerTests.cs b/tests/ProductCatalog.Tests/Features/Product/Commands/ProductBatchCommandHandlerTests.cs index c9100a1c..88ebc366 100644 --- a/tests/ProductCatalog.Tests/Features/Product/Commands/ProductBatchCommandHandlerTests.cs +++ b/tests/ProductCatalog.Tests/Features/Product/Commands/ProductBatchCommandHandlerTests.cs @@ -9,7 +9,6 @@ using ProductCatalog.Domain.Entities; using ProductCatalog.Domain.Entities.ProductData; using ProductCatalog.Domain.Interfaces; -using SharedKernel.Application.Batch.Rules; using SharedKernel.Domain.Interfaces; using Shouldly; using Wolverine; @@ -82,7 +81,7 @@ public async Task CreateHandleAsync_WhenReferencesAreMissing_ReturnsMergedBatchF _productDataRepositoryMock.Object, _unitOfWorkMock.Object, _busMock.Object, - CreateBatchRule(new CreateProductRequestValidator()), + new CreateProductRequestValidator(), TimeProvider.System, CancellationToken.None ); @@ -161,7 +160,7 @@ public async Task CreateHandleAsync_WhenItemsAreValid_PersistsProductsAndPublish _productDataRepositoryMock.Object, _unitOfWorkMock.Object, _busMock.Object, - CreateBatchRule(new CreateProductRequestValidator()), + new CreateProductRequestValidator(), TimeProvider.System, CancellationToken.None ); @@ -208,7 +207,7 @@ public async Task LoadAsync_WhenProductIsMissing_ReturnsStopWithoutLookup() _productRepositoryMock.Object, _categoryRepositoryMock.Object, _productDataRepositoryMock.Object, - CreateBatchRule(new UpdateProductItemValidator()), + new UpdateProductItemValidator(), CancellationToken.None ); @@ -297,12 +296,4 @@ public async Task HandleAsync_WhenProductDataIdsAreNull_LeavesExistingLinksUntou Times.Once ); } - - private static FluentValidationBatchRule CreateBatchRule( - FluentValidation.IValidator validator - ) - { - Mock metrics = new(); - return new FluentValidationBatchRule(validator, metrics.Object); - } } From f307973e5b38c88140777f3f5c4a43c3a648b936 Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Tue, 31 Mar 2026 21:46:51 +0200 Subject: [PATCH 10/14] feat: Enhance observability and logging by refining telemetry integration and cache metrics handling --- .../ApiExceptionHandlerLogs.cs | 4 +- .../Extensions/ObservabilityExtensions.cs | 61 +++++++++++++++---- .../TenantAwareOutputCachePolicy.cs | 13 +++- .../HealthCheckMetricsPublisher.cs | 9 ++- 4 files changed, 69 insertions(+), 18 deletions(-) diff --git a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs index 56f6992f..75336baf 100644 --- a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs +++ b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs @@ -15,7 +15,7 @@ public static partial void UnhandledException( Exception exception, int statusCode, [SensitiveData] string errorCode, - [PersonalData] string traceId + string traceId ); [LoggerMessage( @@ -28,6 +28,6 @@ public static partial void HandledApplicationException( Exception exception, int statusCode, [SensitiveData] string errorCode, - [PersonalData] string traceId + string traceId ); } diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs index a73a5a32..934812c8 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs @@ -19,7 +19,11 @@ public static IServiceCollection AddSharedObservability( string serviceName ) { - string otlpEndpoint = configuration["Observability:Otlp:Endpoint"] ?? "http://alloy:4317"; + ObservabilityOptions observabilityOptions = GetObservabilityOptions(configuration); + IReadOnlyList otlpEndpoints = GetEnabledOtlpEndpoints( + observabilityOptions, + environment + ); services .AddOpenTelemetry() @@ -37,7 +41,8 @@ string serviceName if (environment.IsDevelopment()) tracing.AddConsoleExporter(); - tracing.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)); + foreach (string endpoint in otlpEndpoints) + tracing.AddOtlpExporter(o => o.Endpoint = new Uri(endpoint)); }) .WithMetrics(metrics => { @@ -48,7 +53,8 @@ string serviceName .AddProcessInstrumentation() .AddMeter("Wolverine"); - metrics.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)); + foreach (string endpoint in otlpEndpoints) + metrics.AddOtlpExporter(o => o.Endpoint = new Uri(endpoint)); }); return services; @@ -65,18 +71,18 @@ IHostEnvironment environment { List endpoints = []; - bool aspireEnabled = options.Exporters.Aspire.Enabled ?? environment.IsDevelopment(); - if (aspireEnabled) + if (IsAspireExporterEnabled(options, environment)) { - endpoints.Add( - string.IsNullOrWhiteSpace(options.Aspire.Endpoint) - ? TelemetryDefaults.AspireOtlpEndpoint - : options.Aspire.Endpoint - ); + var aspireEndpoint = string.IsNullOrWhiteSpace(options.Aspire.Endpoint) + ? TelemetryDefaults.AspireOtlpEndpoint + : options.Aspire.Endpoint; + endpoints.Add(aspireEndpoint); } - bool otlpEnabled = options.Exporters.Otlp.Enabled ?? !environment.IsDevelopment(); - if (otlpEnabled && !string.IsNullOrWhiteSpace(options.Otlp.Endpoint)) + if ( + IsOtlpExporterEnabled(options, environment) + && !string.IsNullOrWhiteSpace(options.Otlp.Endpoint) + ) { endpoints.Add(options.Otlp.Endpoint); } @@ -84,6 +90,37 @@ IHostEnvironment environment return endpoints.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } + /// + /// Returns whether the Aspire OTLP exporter is active: uses the explicit configuration value + /// when set, otherwise defaults to true in Development outside a container. + /// + internal static bool IsAspireExporterEnabled( + ObservabilityOptions options, + IHostEnvironment environment + ) => + options.Exporters.Aspire.Enabled + ?? (environment.IsDevelopment() && !IsRunningInContainer()); + + /// + /// Returns whether the generic OTLP exporter is active: uses the explicit configuration value + /// when set, otherwise defaults to true when running in a container. + /// + internal static bool IsOtlpExporterEnabled( + ObservabilityOptions options, + IHostEnvironment environment + ) => options.Exporters.Otlp.Enabled ?? IsRunningInContainer(); + + /// Returns whether the console/stdout exporter is enabled; defaults to false. + internal static bool IsConsoleExporterEnabled(ObservabilityOptions options) => + options.Exporters.Console.Enabled ?? false; + + private static bool IsRunningInContainer() => + string.Equals( + Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), + "true", + StringComparison.OrdinalIgnoreCase + ); + internal static Dictionary BuildResourceAttributes( string serviceName, IHostEnvironment environment diff --git a/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs b/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs index f45eadd5..c2b3c99a 100644 --- a/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs +++ b/src/SharedKernel/SharedKernel.Api/OutputCaching/TenantAwareOutputCachePolicy.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.OutputCaching; using SharedKernel.Application.Security; +using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.OutputCaching; @@ -59,10 +60,18 @@ CancellationToken cancellationToken public ValueTask ServeFromCacheAsync( OutputCacheContext context, CancellationToken cancellationToken - ) => ValueTask.CompletedTask; + ) + { + CacheTelemetry.RecordCacheHit(context); + return ValueTask.CompletedTask; + } public ValueTask ServeResponseAsync( OutputCacheContext context, CancellationToken cancellationToken - ) => ValueTask.CompletedTask; + ) + { + CacheTelemetry.RecordResponseOutcome(context); + return ValueTask.CompletedTask; + } } diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs index 532c171a..86402500 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs @@ -14,6 +14,9 @@ public sealed class HealthCheckMetricsPublisher : IHealthCheckPublisher ); // Static gauge — registering multiple instances on the same Meter causes duplicate metrics. + // DESIGN NOTE: The gauge is static to prevent duplicate metric registrations. + // If Meter ever becomes instance-based, move gauge registration into constructor + // and ensure singleton lifecycle or use WeakReference to prevent memory leaks. private static readonly ObservableGauge Gauge = ObservabilityConventions.SharedHealthMeter.CreateObservableGauge( TelemetryMetricNames.HealthStatus, @@ -30,12 +33,14 @@ public Task PublishAsync(HealthReport report, CancellationToken cancellationToke return Task.CompletedTask; } - internal static IReadOnlyDictionary SnapshotStatuses() => - Statuses.ToDictionary( + internal static IReadOnlyDictionary SnapshotStatuses() + { + return Statuses.ToDictionary( entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase ); + } private static IEnumerable> ObserveStatuses() { From 5ed98219f24ac074942692612e99af48f95e979a Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Tue, 31 Mar 2026 22:21:20 +0200 Subject: [PATCH 11/14] feat: Enhance logging by adding personal data attributes to exception handling and updating test repository mock behavior --- .../Commands/UpdateCategoriesCommand.cs | 2 +- .../ApiExceptionHandlerLogs.cs | 8 +++---- .../CategoryBatchCommandHandlerTests.cs | 21 +++++++++++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/UpdateCategoriesCommand.cs b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/UpdateCategoriesCommand.cs index 118c7bb5..98d8a8d5 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/UpdateCategoriesCommand.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Application/Features/Category/Commands/UpdateCategoriesCommand.cs @@ -41,7 +41,7 @@ await context.ApplyRulesAsync( .Select(item => item.Id) .ToHashSet(); Dictionary categoryMap = ( - await repository.ListAsync(new CategoriesByIdsSpecification(requestedIds), ct) + await repository.ListAsync(new CategoriesByIdsSpecification(requestedIds), ct) ?? [] ).ToDictionary(c => c.Id); await context.ApplyRulesAsync( diff --git a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs index 75336baf..40d69335 100644 --- a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs +++ b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs @@ -14,8 +14,8 @@ public static partial void UnhandledException( this ILogger logger, Exception exception, int statusCode, - [SensitiveData] string errorCode, - string traceId + [SensitiveDataAttribute] string errorCode, + [PersonalDataAttribute] string traceId ); [LoggerMessage( @@ -27,7 +27,7 @@ public static partial void HandledApplicationException( this ILogger logger, Exception exception, int statusCode, - [SensitiveData] string errorCode, - string traceId + [SensitiveDataAttribute] string errorCode, + [PersonalDataAttribute] string traceId ); } diff --git a/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs b/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs index 93883bd6..9bd1e090 100644 --- a/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs +++ b/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs @@ -28,6 +28,16 @@ public CategoryBatchCommandHandlerTests() ) ) .Returns, CancellationToken, object?>((action, _, _) => action()); + + // Default mock behavior for repository - return empty list unless overridden + _repositoryMock + .Setup(r => + r.ListAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync(new List()); } [Fact] @@ -153,6 +163,17 @@ public async Task HandleAsync_WhenLookupContainsEntities_UpdatesEachCategory() ]) ); + // Override the default mock for this specific test to return the categories + _repositoryMock.Reset(); + _repositoryMock + .Setup(r => + r.ListAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync([first, second]); + var (result, _) = await UpdateCategoriesCommandHandler.HandleAsync( command, _repositoryMock.Object, From 43835c80a5add6696ab2cd882421c164119400b6 Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Tue, 31 Mar 2026 23:07:28 +0200 Subject: [PATCH 12/14] feat: Enhance exception handling by adding specific NullReferenceException catch for RabbitMQ channel issues --- tests/Integration.Tests/Factories/ServiceFactoryBase.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Integration.Tests/Factories/ServiceFactoryBase.cs b/tests/Integration.Tests/Factories/ServiceFactoryBase.cs index f98b5bc2..0151d2fa 100644 --- a/tests/Integration.Tests/Factories/ServiceFactoryBase.cs +++ b/tests/Integration.Tests/Factories/ServiceFactoryBase.cs @@ -45,6 +45,13 @@ await TestDatabaseLifecycle.CreateDatabaseAsync( await base.DisposeAsync(); } catch (OperationCanceledException) { } + catch (NullReferenceException ex) + when (ex.ToString() + .Contains( + "Wolverine.RabbitMQ.Internal.RabbitMqChannelAgent", + StringComparison.Ordinal + ) + ) { } catch (AggregateException ex) when (ex.InnerExceptions.All(e => e is OperationCanceledException or TaskCanceledException From 387698e28357df001b6e49f5bee0c14fca9b399b Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Tue, 31 Mar 2026 23:07:42 +0200 Subject: [PATCH 13/14] feat: Improve exception handling by refining catch blocks for RabbitMQ channel and aggregate exceptions From 889b3dbe04e8baaf6aae47a73d575941a3a4f6af Mon Sep 17 00:00:00 2001 From: Tadeas Zribko Date: Wed, 1 Apr 2026 09:55:28 +0200 Subject: [PATCH 14/14] fix: Handle NullReferenceException during RabbitMQ teardown and simplify async disposal in tests --- .../Integration.Tests/Factories/ServiceFactoryBase.cs | 11 ++++++++--- .../Infrastructure/OutputCacheBehaviorTests.cs | 10 ++++------ .../Commands/CategoryBatchCommandHandlerTests.cs | 9 +++++++++ 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/Integration.Tests/Factories/ServiceFactoryBase.cs b/tests/Integration.Tests/Factories/ServiceFactoryBase.cs index f98b5bc2..4be71c71 100644 --- a/tests/Integration.Tests/Factories/ServiceFactoryBase.cs +++ b/tests/Integration.Tests/Factories/ServiceFactoryBase.cs @@ -45,10 +45,15 @@ await TestDatabaseLifecycle.CreateDatabaseAsync( await base.DisposeAsync(); } catch (OperationCanceledException) { } + catch (NullReferenceException) { } // Wolverine RabbitMQ teardown bug: https://github.com/JasperFx/wolverine/issues catch (AggregateException ex) - when (ex.InnerExceptions.All(e => - e is OperationCanceledException or TaskCanceledException - ) + when (ex.Flatten() + .InnerExceptions.All(e => + e + is OperationCanceledException + or TaskCanceledException + or NullReferenceException + ) ) { } await TestDatabaseLifecycle.DropDatabaseAsync( diff --git a/tests/Integration.Tests/Infrastructure/OutputCacheBehaviorTests.cs b/tests/Integration.Tests/Infrastructure/OutputCacheBehaviorTests.cs index 40bde8e6..b6c2a60a 100644 --- a/tests/Integration.Tests/Infrastructure/OutputCacheBehaviorTests.cs +++ b/tests/Integration.Tests/Infrastructure/OutputCacheBehaviorTests.cs @@ -48,12 +48,10 @@ await Task.WhenAll( public async ValueTask DisposeAsync() { - await Task.WhenAll( - _fileStorageFactory.DisposeAsync().AsTask(), - _reviewsFactory.DisposeAsync().AsTask(), - _identityFactory.DisposeAsync().AsTask(), - _productCatalogFactory.DisposeAsync().AsTask() - ); + await _fileStorageFactory.DisposeAsync(); + await _reviewsFactory.DisposeAsync(); + await _identityFactory.DisposeAsync(); + await _productCatalogFactory.DisposeAsync(); } [Fact] diff --git a/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs b/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs index 93883bd6..e2ceb65c 100644 --- a/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs +++ b/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs @@ -153,6 +153,15 @@ public async Task HandleAsync_WhenLookupContainsEntities_UpdatesEachCategory() ]) ); + _repositoryMock + .Setup(r => + r.ListAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync([first, second]); + var (result, _) = await UpdateCategoriesCommandHandler.HandleAsync( command, _repositoryMock.Object,