From 83eae661bf714bfa1c55c2885051a70d07df45b0 Mon Sep 17 00:00:00 2001 From: mirage <119869686+ClementBobin@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:50:19 +0200 Subject: [PATCH] =?UTF-8?q?feat=C2=A0:=20ajout=20d'un=20intercepteur=20de?= =?UTF-8?q?=20requ=C3=AAtes=20lentes=20EF=20Core=20et=20une=20configuratio?= =?UTF-8?q?n=20des=20options=20de=20performance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Webzine.Business/Webzine.Business.csproj | 1 + .../Configuration/Middlewares.cs | 13 ++ .../Interceptors/EfSlowQueryInterceptor.cs | 146 ++++++++++++++++++ Webzine.WebApplication/Program.cs | 23 ++- Webzine.WebApplication/appsettings.json | 5 +- 5 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 Webzine.WebApplication/Configuration/Middlewares.cs create mode 100644 Webzine.WebApplication/Interceptors/EfSlowQueryInterceptor.cs diff --git a/Webzine.Business/Webzine.Business.csproj b/Webzine.Business/Webzine.Business.csproj index 6e5a6aa..a2efab2 100644 --- a/Webzine.Business/Webzine.Business.csproj +++ b/Webzine.Business/Webzine.Business.csproj @@ -23,6 +23,7 @@ + diff --git a/Webzine.WebApplication/Configuration/Middlewares.cs b/Webzine.WebApplication/Configuration/Middlewares.cs new file mode 100644 index 0000000..bbfe61e --- /dev/null +++ b/Webzine.WebApplication/Configuration/Middlewares.cs @@ -0,0 +1,13 @@ +namespace Webzine.WebApplication.Configuration; + +/// +/// Options de seuil pour la détection des opérations EF Core lentes. +/// +public class EfPerformanceOptions +{ + /// + /// Obtient ou définit le seuil en millisecondes au-delà duquel une commande SQL est journalisée. + /// Valeur par défaut : 200 ms. + /// + public int SeuilMs { get; set; } = 200; +} diff --git a/Webzine.WebApplication/Interceptors/EfSlowQueryInterceptor.cs b/Webzine.WebApplication/Interceptors/EfSlowQueryInterceptor.cs new file mode 100644 index 0000000..5e629a8 --- /dev/null +++ b/Webzine.WebApplication/Interceptors/EfSlowQueryInterceptor.cs @@ -0,0 +1,146 @@ +namespace Webzine.WebApplication.Interceptors; + +using System.Data.Common; +using System.Diagnostics; + +using Configuration; + +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Options; + +/// +/// Intercepteur EF Core qui journalise uniquement les commandes SQL dépassant le seuil configuré. +/// Remonte la pile d'appels pour identifier la méthode repository (Webzine.Repository.*) à l'origine de la requête. +/// +/// +/// +/// Références : +/// +/// +/// EF Core interceptors (doc officielle) : +/// +/// +/// +/// API : +/// +/// +/// +/// Exemple de slow-query interceptor (SO) : +/// +/// +/// +/// pour remonter l'appelant : +/// +/// +/// +/// Enregistrement via AddInterceptors : +/// +/// +/// +/// +/// +public class EfSlowQueryInterceptor : DbCommandInterceptor +{ + private readonly ILogger logger; + private readonly int seuilMs; + + /// + /// Initializes a new instance of the class. + /// + /// Le service de journalisation injecté pour suivre les opérations de l'intercepteur. + /// Les options de performance EF injectées pour récupérer le seuil de lenteur configuré. + public EfSlowQueryInterceptor(ILogger logger, IOptions options) + { + this.logger = logger; + this.seuilMs = options.Value.SeuilMs; + + this.logger.LogDebug("[EfSlowQueryInterceptor] Constructeur appelé — seuil : {SeuilMs} ms.", this.seuilMs); + } + + /// + public override DbDataReader ReaderExecuted(DbCommand command, CommandExecutedEventData eventData, DbDataReader result) + { + this.JournaliserSiLent(eventData.Duration); + return result; + } + + /// + public override ValueTask ReaderExecutedAsync(DbCommand command, CommandExecutedEventData eventData, DbDataReader result, CancellationToken cancellationToken = default) + { + this.JournaliserSiLent(eventData.Duration); + return ValueTask.FromResult(result); + } + + /// + public override int NonQueryExecuted(DbCommand command, CommandExecutedEventData eventData, int result) + { + this.JournaliserSiLent(eventData.Duration); + return result; + } + + /// + public override ValueTask NonQueryExecutedAsync(DbCommand command, CommandExecutedEventData eventData, int result, CancellationToken cancellationToken = default) + { + this.JournaliserSiLent(eventData.Duration); + return ValueTask.FromResult(result); + } + + /// + public override object? ScalarExecuted(DbCommand command, CommandExecutedEventData eventData, object? result) + { + this.JournaliserSiLent(eventData.Duration); + return result; + } + + /// + public override ValueTask ScalarExecutedAsync(DbCommand command, CommandExecutedEventData eventData, object? result, CancellationToken cancellationToken = default) + { + this.JournaliserSiLent(eventData.Duration); + return ValueTask.FromResult(result); + } + + /// + /// Remonte la pile d'appels pour trouver la première méthode dans Webzine.Repository. + /// Toutes les requêtes EF Core du projet transitent par ce namespace, ce qui garantit + /// un résultat pertinent sans parcourir l'intégralité de la stack. + /// + /// Chaîne Classe.Méthode ou "inconnu" si rien trouvé. + /// + /// est instancié uniquement quand le seuil est dépassé, + /// ce qui évite tout impact sur le chemin nominal. + /// Ref : . + /// + private static string TrouverAppelantRepository() + { + // skipFrames: 1 pour sauter TrouverAppelantRepository elle-même + // fNeedFileInfo: false — on ne veut pas les numéros de ligne (coût supplémentaire inutile) + var frames = new StackTrace(skipFrames: 1, fNeedFileInfo: false).GetFrames(); + + foreach (var frame in frames) + { + var methode = frame.GetMethod(); + if (methode?.DeclaringType?.Namespace?.StartsWith("Webzine.Repository", StringComparison.Ordinal) == true) + { + return $"{methode.DeclaringType.Name}.{methode.Name}"; + } + } + + return "inconnu"; + } + + private void JournaliserSiLent(TimeSpan duree) + { + if (duree.TotalMilliseconds > this.seuilMs) + { + var appelant = TrouverAppelantRepository(); + + this.logger.LogWarning( + "[EfSlowQueryInterceptor] Opération EF Core lente détectée — durée réelle : {DureeMs} ms — seuil : {SeuilMs} ms — dépassement : +{Depassement} ms.{NewLine}Appelant : {Appelant}", + duree.TotalMilliseconds.ToString("F2"), + this.seuilMs, + (duree.TotalMilliseconds - this.seuilMs).ToString("F2"), + Environment.NewLine, + appelant); + } + } +} \ No newline at end of file diff --git a/Webzine.WebApplication/Program.cs b/Webzine.WebApplication/Program.cs index d118c67..1e17c43 100644 --- a/Webzine.WebApplication/Program.cs +++ b/Webzine.WebApplication/Program.cs @@ -15,6 +15,7 @@ using Webzine.Repository; using Webzine.Repository.Contracts; using Webzine.WebApplication.Configuration; using Webzine.WebApplication.Extensions; +using Webzine.WebApplication.Interceptors; // Initiation du logger NLog pour la classe courante afin de pouvoir l'utiliser pour logger des messages d'information, d'erreur, etc avant la construction de l'application. var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger(); @@ -37,6 +38,12 @@ try builder.Logging.ClearProviders(); builder.Host.UseNLog(); + builder.Services.Configure(options => + { + options.SeuilMs = builder.Configuration.GetValue("EfPerformance:SeuilMs"); + }); + builder.Services.AddSingleton(); + // En fonction de la configuration, utilise soit les repositories basés sur une base de données, soit les repositories basés sur des listes locales. var repositoryType = builder.Configuration.GetValue("Repository"); var seederType = builder.Configuration.GetValue("Seeder"); @@ -45,13 +52,21 @@ try { if (builder.Environment.IsProduction()) { - builder.Services.AddDbContext(options => - options.UseNpgsql(builder.Configuration.GetConnectionString("PostGreSQLConnection"))); + builder.Services.AddDbContext((serviceProvider, options) => + { + options + .UseNpgsql(builder.Configuration.GetConnectionString("PostGreSQLConnection")) + .AddInterceptors(serviceProvider.GetRequiredService()); + }); } else { - builder.Services.AddDbContext(options => - options.UseSqlite(builder.Configuration.GetConnectionString("SqliteConnection"))); + builder.Services.AddDbContext((serviceProvider, options) => + { + options + .UseSqlite(builder.Configuration.GetConnectionString("SqliteConnection")) + .AddInterceptors(serviceProvider.GetRequiredService()); + }); } builder.Services.AddScoped(); diff --git a/Webzine.WebApplication/appsettings.json b/Webzine.WebApplication/appsettings.json index 20bd894..46fd8b0 100644 --- a/Webzine.WebApplication/appsettings.json +++ b/Webzine.WebApplication/appsettings.json @@ -13,5 +13,8 @@ "SqliteConnection": "Data Source=Data/webzine.sqlite", "PostGreSQLConnection": "Host=localhost;Port=5432;Username=admin;Password=admin123;Database=webzine_db" }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "EfPerformance": { + "SeuilMs": 10 + } } \ No newline at end of file