Merge pull request 'j3/feat/interceptor-efcore' (#186) from j3/feat/interceptor-efcore into dev

Reviewed-on: https://10.4.0.131/gitea/DI1-P4-E1/Webzine/pulls/186
This commit is contained in:
j.vetu
2026-04-02 14:36:31 +02:00
6 changed files with 184 additions and 6 deletions

View File

@@ -23,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="..\Webzine.Business.Contracts\Webzine.Business.Contracts.csproj" />
<ProjectReference Include="..\Webzine.Entity\Webzine.Entity.csproj" />
<ProjectReference Include="..\Webzine.Repository.Contracts\Webzine.Repository.Contracts.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,13 @@
namespace Webzine.WebApplication.Configuration;
/// <summary>
/// Options de seuil pour la détection des opérations EF Core lentes.
/// </summary>
public class EfPerformanceOptions
{
/// <summary>
/// Obtient ou définit le seuil en millisecondes au-delà duquel une commande SQL est journalisée.
/// Valeur par défaut : 200 ms.
/// </summary>
public int SeuilMs { get; set; } = 200;
}

View File

@@ -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;
/// <summary>
/// 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 (<c>Webzine.Repository.*</c>) à l'origine de la requête.
/// </summary>
/// <remarks>
/// <para>
/// <b>Références :</b>
/// <list type="bullet">
/// <item>
/// EF Core interceptors (doc officielle) :
/// <see href="https://learn.microsoft.com/en-us/ef/core/logging-events-diagnostics/interceptors"/>
/// </item>
/// <item>
/// <see cref="DbCommandInterceptor"/> API :
/// <see href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.diagnostics.dbcommandinterceptor"/>
/// </item>
/// <item>
/// Exemple de slow-query interceptor (SO) :
/// <see href="https://medium.com/@sudipdevdev/how-to-detect-and-log-slow-queries-in-entity-framework-core-e2ab71024849"/>
/// </item>
/// <item>
/// <see cref="System.Diagnostics.StackTrace"/> pour remonter l'appelant :
/// <see href="https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.stacktrace"/>
/// </item>
/// <item>
/// Enregistrement via <c>AddInterceptors</c> :
/// <see href="https://learn.microsoft.com/en-us/ef/core/logging-events-diagnostics/interceptors#registering-interceptors"/>
/// </item>
/// </list>
/// </para>
/// </remarks>
public class EfSlowQueryInterceptor : DbCommandInterceptor
{
private readonly ILogger<EfSlowQueryInterceptor> logger;
private readonly int seuilMs;
/// <summary>
/// Initializes a new instance of the <see cref="EfSlowQueryInterceptor"/> class.
/// </summary>
/// <param name="logger">Le service de journalisation injecté pour suivre les opérations de l'intercepteur.</param>
/// <param name="options">Les options de performance EF injectées pour récupérer le seuil de lenteur configuré.</param>
public EfSlowQueryInterceptor(ILogger<EfSlowQueryInterceptor> logger, IOptions<EfPerformanceOptions> options)
{
this.logger = logger;
this.seuilMs = options.Value.SeuilMs;
this.logger.LogDebug("[EfSlowQueryInterceptor] Constructeur appelé — seuil : {SeuilMs} ms.", this.seuilMs);
}
/// <inheritdoc/>
public override DbDataReader ReaderExecuted(DbCommand command, CommandExecutedEventData eventData, DbDataReader result)
{
this.JournaliserSiLent(eventData.Duration);
return result;
}
/// <inheritdoc/>
public override ValueTask<DbDataReader> ReaderExecutedAsync(DbCommand command, CommandExecutedEventData eventData, DbDataReader result, CancellationToken cancellationToken = default)
{
this.JournaliserSiLent(eventData.Duration);
return ValueTask.FromResult(result);
}
/// <inheritdoc/>
public override int NonQueryExecuted(DbCommand command, CommandExecutedEventData eventData, int result)
{
this.JournaliserSiLent(eventData.Duration);
return result;
}
/// <inheritdoc/>
public override ValueTask<int> NonQueryExecutedAsync(DbCommand command, CommandExecutedEventData eventData, int result, CancellationToken cancellationToken = default)
{
this.JournaliserSiLent(eventData.Duration);
return ValueTask.FromResult(result);
}
/// <inheritdoc/>
public override object? ScalarExecuted(DbCommand command, CommandExecutedEventData eventData, object? result)
{
this.JournaliserSiLent(eventData.Duration);
return result;
}
/// <inheritdoc/>
public override ValueTask<object?> ScalarExecutedAsync(DbCommand command, CommandExecutedEventData eventData, object? result, CancellationToken cancellationToken = default)
{
this.JournaliserSiLent(eventData.Duration);
return ValueTask.FromResult(result);
}
/// <summary>
/// Remonte la pile d'appels pour trouver la première méthode dans <c>Webzine.Repository</c>.
/// 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.
/// </summary>
/// <returns>Chaîne <c>Classe.Méthode</c> ou <c>"inconnu"</c> si rien trouvé.</returns>
/// <remarks>
/// <see cref="StackTrace"/> est instancié uniquement quand le seuil est dépassé,
/// ce qui évite tout impact sur le chemin nominal.
/// Ref : <see href="https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.stacktrace"/>.
/// </remarks>
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);
}
}
}

View File

@@ -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<EfPerformanceOptions>(options =>
{
options.SeuilMs = builder.Configuration.GetValue<int>("EfPerformance:SeuilMs");
});
builder.Services.AddSingleton<EfSlowQueryInterceptor>();
// 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<RepositoryType>("Repository");
var seederType = builder.Configuration.GetValue<SeederType>("Seeder");
@@ -45,13 +52,21 @@ try
{
if (builder.Environment.IsProduction())
{
builder.Services.AddDbContext<WebzineDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("PostGreSQLConnection")));
builder.Services.AddDbContext<WebzineDbContext>((serviceProvider, options) =>
{
options
.UseNpgsql(builder.Configuration.GetConnectionString("PostGreSQLConnection"))
.AddInterceptors(serviceProvider.GetRequiredService<EfSlowQueryInterceptor>());
});
}
else
{
builder.Services.AddDbContext<WebzineDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("SqliteConnection")));
builder.Services.AddDbContext<WebzineDbContext>((serviceProvider, options) =>
{
options
.UseSqlite(builder.Configuration.GetConnectionString("SqliteConnection"))
.AddInterceptors(serviceProvider.GetRequiredService<EfSlowQueryInterceptor>());
});
}
builder.Services.AddScoped<DbEntityRepository>();

View File

@@ -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
}
}

View File

@@ -22,7 +22,7 @@
layout="${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=tostring}|${aspnet-request-url:whenEmpty=NoRequest}" />
<!-- Console pour debug immédiat -->
<target xsi:type="Console" name="console"
<target xsi:type="Console" name="Info"
layout="${longdate}|${level:uppercase=true}|${logger}|${message}" />
</targets>