From c5c46cb1b021da86b6efc7c9a8328cfd65c4d564 Mon Sep 17 00:00:00 2001 From: Stuart Ferguson Date: Wed, 25 Feb 2026 05:49:57 +0000 Subject: [PATCH 1/2] Add EF query timing interceptor and update DbContext resolver Introduced QueryTimingInterceptor to log slow EF queries based on a configurable threshold. Replaced DbContextResolver with DbContextResolverX to ensure the interceptor is attached to all DbContext instances, including those with dynamic database names. Updated DI registration to use AddDbContextFactory and singleton interceptor. --- .../QueryTimingInterceptor.cs | 98 +++++++++++++++++++ .../Bootstrapper/RepositoryRegistry.cs | 19 ++-- 2 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 EstateReportingAPI.BusinessLogic/QueryTimingInterceptor.cs diff --git a/EstateReportingAPI.BusinessLogic/QueryTimingInterceptor.cs b/EstateReportingAPI.BusinessLogic/QueryTimingInterceptor.cs new file mode 100644 index 0000000..fc76599 --- /dev/null +++ b/EstateReportingAPI.BusinessLogic/QueryTimingInterceptor.cs @@ -0,0 +1,98 @@ +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Shared.EntityFramework; +using Shared.Logger; +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Diagnostics; +using System.Text; +using Shared.General; + +namespace EstateReportingAPI.BusinessLogic; + +public class QueryTimingInterceptor : DbCommandInterceptor { + + internal void LogIfRequired(DbCommand command, + CommandExecutedEventData eventData) { + + Int32 threshold = ConfigurationReader.GetValueOrDefault("AppSettings", "EFQueryPerformanceThresholdMs", 500); + + if (eventData.Duration.TotalMilliseconds < threshold) + return; + Logger.LogWarning($"PERFORMANCE - EF Query took {eventData.Duration.TotalMilliseconds} ms\n{command.CommandText}\n"); + } + + public override DbDataReader ReaderExecuted(DbCommand command, + CommandExecutedEventData eventData, + DbDataReader result) { + LogIfRequired(command, eventData); + return result; + } + + public override async ValueTask ReaderExecutedAsync(DbCommand command, + CommandExecutedEventData eventData, + DbDataReader result, + CancellationToken cancellationToken = new CancellationToken()) { + LogIfRequired(command, eventData); + + return result; + } +} + + +public class DbContextResolverX : IDbContextResolver where TContext : DbContext +{ + private readonly IServiceProvider _rootProvider; + private readonly IConfiguration _config; + private readonly DbCommandInterceptor Interceptor; + + public DbContextResolverX(IServiceProvider rootProvider, + IConfiguration config, + DbCommandInterceptor interceptor) + { + _rootProvider = rootProvider; + _config = config; + this.Interceptor = interceptor; + } + + public ResolvedDbContext Resolve(String connectionStringKey) + { + return this.Resolve(connectionStringKey, String.Empty); + } + + public ResolvedDbContext Resolve(String connectionStringKey, + String databaseNameSuffix) + { + IServiceScope scope = _rootProvider.CreateScope(); + String connectionString = _config.GetConnectionString(connectionStringKey); + if (String.IsNullOrWhiteSpace(connectionString)) + throw new InvalidOperationException($"Connection string for '{connectionStringKey}' not found."); + + // Update the connection string with the identifier if needed + if (!String.IsNullOrWhiteSpace(databaseNameSuffix)) + { + SqlConnectionStringBuilder builder = new(connectionString); + builder.InitialCatalog = $"{builder.InitialCatalog}-{databaseNameSuffix}"; + connectionString = builder.ConnectionString; + + + // Create an isolated service collection and provider + ServiceCollection services = new(); + services.AddDbContext(options => { + options.UseSqlServer(connectionString); + options.AddInterceptors(Interceptor); // attach here + }); + + ServiceProvider provider = services.BuildServiceProvider(); + scope = provider.CreateScope(); + // Standard resolution using DI container + } + + return new ResolvedDbContext(scope); + } +} diff --git a/EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs b/EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs index dc22aea..e034ca7 100644 --- a/EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs +++ b/EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs @@ -1,4 +1,5 @@ -using TransactionProcessor.Database.Contexts; +using Microsoft.EntityFrameworkCore.Diagnostics; +using TransactionProcessor.Database.Contexts; namespace EstateReportingAPI.Bootstrapper; @@ -6,6 +7,7 @@ namespace EstateReportingAPI.Bootstrapper; using Common; using Lamar; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Internal; using Shared.EntityFramework; using Shared.General; using System.Diagnostics.CodeAnalysis; @@ -18,16 +20,19 @@ public RepositoryRegistry(){ { this.AddSingleton(); } - - this.AddSingleton(typeof(IDbContextResolver<>), typeof(DbContextResolver<>)); + this.AddSingleton(); + this.AddSingleton(typeof(IDbContextResolver<>), typeof(DbContextResolverX<>)); if (Startup.WebHostEnvironment.IsEnvironment("IntegrationTest") || Startup.Configuration.GetValue("ServiceOptions:UseInMemoryDatabase") == true) { this.AddDbContext(builder => builder.UseInMemoryDatabase("TransactionProcessorReadModel")); } - else - { - this.AddDbContext(options => - options.UseSqlServer(ConfigurationReader.GetConnectionString("TransactionProcessorReadModel"))); + else { + this.AddSingleton(); + this.AddDbContextFactory((sp, options) => + { + options.UseSqlServer(ConfigurationReader.GetConnectionString("TransactionProcessorReadModel")); + options.AddInterceptors(sp.GetRequiredService()); + }); } } } \ No newline at end of file From 935673e8190f23abfc8482c985c6c7ea3521fb80 Mon Sep 17 00:00:00 2001 From: Stuart Ferguson Date: Wed, 25 Feb 2026 05:56:04 +0000 Subject: [PATCH 2/2] oops --- EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs b/EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs index e034ca7..917f5e3 100644 --- a/EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs +++ b/EstateReportingAPI/Bootstrapper/RepositoryRegistry.cs @@ -28,7 +28,7 @@ public RepositoryRegistry(){ } else { this.AddSingleton(); - this.AddDbContextFactory((sp, options) => + this.AddDbContext((sp, options) => { options.UseSqlServer(ConfigurationReader.GetConnectionString("TransactionProcessorReadModel")); options.AddInterceptors(sp.GetRequiredService());