Interceptors for Logging & Monitoring
Overview
Interceptors allow you to intercept EF Core operations like queries, SaveChanges, and connections. Perfect for logging, auditing, performance monitoring, and modifying behavior.
Types of Interceptors
- SaveChangesInterceptor: Intercept SaveChanges operations
- DbCommandInterceptor: Intercept SQL commands
- DbConnectionInterceptor: Intercept database connections
- DbTransactionInterceptor: Intercept transactions
Slow Query Interceptor
public class SlowQueryInterceptor : DbCommandInterceptor
{
private readonly ILogger<SlowQueryInterceptor> _logger;
private readonly int _slowQueryThresholdMs;
public SlowQueryInterceptor(
ILogger<SlowQueryInterceptor> logger,
int slowQueryThresholdMs = 1000)
{
_logger = logger;
_slowQueryThresholdMs = slowQueryThresholdMs;
}
public override async ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
var duration = eventData.Duration.TotalMilliseconds;
if (duration > _slowQueryThresholdMs)
{
_logger.LogWarning(
"Slow query detected ({Duration}ms): {CommandText}",
duration,
command.CommandText);
}
return await base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
}
}
// Registration
optionsBuilder.AddInterceptors(new SlowQueryInterceptor(logger, 500));
Audit Interceptor
public class AuditInterceptor : SaveChangesInterceptor
{
private readonly ICurrentUserService _currentUser;
public AuditInterceptor(ICurrentUserService currentUser)
{
_currentUser = currentUser;
}
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
var context = eventData.Context;
if (context == null) return result;
var entries = context.ChangeTracker.Entries()
.Where(e => e.State == EntityState.Added ||
e.State == EntityState.Modified ||
e.State == EntityState.Deleted);
foreach (var entry in entries)
{
var auditLog = new AuditLog
{
EntityName = entry.Metadata.Name,
EntityId = entry.Property("Id").CurrentValue?.ToString(),
Action = entry.State.ToString(),
UserId = _currentUser.UserId,
Timestamp = DateTime.UtcNow,
Changes = GetChanges(entry)
};
context.Set<AuditLog>().Add(auditLog);
}
return base.SavingChanges(eventData, result);
}
private string GetChanges(EntityEntry entry)
{
var changes = new Dictionary<string, object>();
foreach (var property in entry.Properties)
{
if (property.IsModified)
{
changes[property.Metadata.Name] = new
{
Old = property.OriginalValue,
New = property.CurrentValue
};
}
}
return JsonSerializer.Serialize(changes);
}
}
Query Logging Interceptor
public class QueryLoggingInterceptor : DbCommandInterceptor
{
private readonly ILogger<QueryLoggingInterceptor> _logger;
public QueryLoggingInterceptor(ILogger<QueryLoggingInterceptor> logger)
{
_logger = logger;
}
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
_logger.LogInformation(
"Executing query: {CommandText}",
command.CommandText);
return base.ReaderExecuting(command, eventData, result);
}
public override async ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"Executing async query: {CommandText}",
command.CommandText);
return await base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
}
Performance Metrics Interceptor
public class PerformanceInterceptor : DbCommandInterceptor
{
private readonly IMetricsService _metrics;
public PerformanceInterceptor(IMetricsService metrics)
{
_metrics = metrics;
}
public override async ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
// Record metrics
_metrics.RecordQueryDuration(
commandType: command.CommandType.ToString(),
duration: eventData.Duration.TotalMilliseconds);
// Track query counts
_metrics.IncrementQueryCount();
// Track by command
var commandName = ExtractCommandName(command.CommandText);
_metrics.RecordCommandExecution(commandName, eventData.Duration);
return await base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
}
private string ExtractCommandName(string sql)
{
// Extract table name from query
var match = Regex.Match(sql, @"FROM\s+\[?(\w+)\]?", RegexOptions.IgnoreCase);
return match.Success ? match.Groups[1].Value : "Unknown";
}
}
Timestamp Interceptor
public class TimestampInterceptor : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
var context = eventData.Context;
if (context == null) return result;
var entries = context.ChangeTracker.Entries()
.Where(e => e.Entity is ITimestampedEntity &&
(e.State == EntityState.Added || e.State == EntityState.Modified));
foreach (var entry in entries)
{
var entity = (ITimestampedEntity)entry.Entity;
if (entry.State == EntityState.Added)
{
entity.CreatedAt = DateTime.UtcNow;
}
entity.UpdatedAt = DateTime.UtcNow;
}
return base.SavingChanges(eventData, result);
}
}
public interface ITimestampedEntity
{
DateTime CreatedAt { get; set; }
DateTime UpdatedAt { get; set; }
}
Connection Resilience Interceptor
public class ConnectionInterceptor : DbConnectionInterceptor
{
private readonly ILogger<ConnectionInterceptor> _logger;
public ConnectionInterceptor(ILogger<ConnectionInterceptor> logger)
{
_logger = logger;
}
public override async Task ConnectionFailedAsync(
DbConnection connection,
ConnectionErrorEventData eventData,
CancellationToken cancellationToken = default)
{
_logger.LogError(
eventData.Exception,
"Connection failed to {Database}",
connection.Database);
await base.ConnectionFailedAsync(connection, eventData, cancellationToken);
}
public override Task ConnectionOpenedAsync(
DbConnection connection,
ConnectionEndEventData eventData,
CancellationToken cancellationToken = default)
{
_logger.LogDebug(
"Connection opened to {Database}",
connection.Database);
return base.ConnectionOpenedAsync(connection, eventData, cancellationToken);
}
}
Registration
Single Interceptor
services.AddDbContext<AppDbContext>(options =>
options
.UseSqlServer(connectionString)
.AddInterceptors(new SlowQueryInterceptor(logger)));
Multiple Interceptors
services.AddDbContext<AppDbContext>((serviceProvider, options) =>
{
var logger = serviceProvider.GetRequiredService<ILogger<SlowQueryInterceptor>>();
var currentUser = serviceProvider.GetRequiredService<ICurrentUserService>();
var metrics = serviceProvider.GetRequiredService<IMetricsService>();
options
.UseSqlServer(connectionString)
.AddInterceptors(
new SlowQueryInterceptor(logger, 500),
new AuditInterceptor(currentUser),
new PerformanceInterceptor(metrics),
new TimestampInterceptor());
});
Scoped Interceptors
// Register as scoped service
services.AddScoped<AuditInterceptor>();
// Add to DbContext
services.AddDbContext<AppDbContext>((serviceProvider, options) =>
{
var interceptor = serviceProvider.GetRequiredService<AuditInterceptor>();
options
.UseSqlServer(connectionString)
.AddInterceptors(interceptor);
});
Real-World Use Cases
N+1 Detection
public class N1DetectorInterceptor : DbCommandInterceptor
{
private int _queryCount = 0;
private DateTime _windowStart = DateTime.UtcNow;
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
var now = DateTime.UtcNow;
if ((now - _windowStart).TotalMilliseconds > 100)
{
_queryCount = 0;
_windowStart = now;
}
_queryCount++;
if (_queryCount > 10)
{
_logger.LogWarning(
"Potential N+1 query problem detected! {Count} queries in 100ms",
_queryCount);
}
return base.ReaderExecuting(command, eventData, result);
}
}
Query Cache
public class QueryCacheInterceptor : DbCommandInterceptor
{
private readonly IMemoryCache _cache;
public override async ValueTask<DbDataReader> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
var cacheKey = command.CommandText;
if (_cache.TryGetValue(cacheKey, out DbDataReader cachedResult))
{
return cachedResult;
}
var reader = await base.ReaderExecutingAsync(
command, eventData, result, cancellationToken);
_cache.Set(cacheKey, reader, TimeSpan.FromMinutes(5));
return reader;
}
}
Testing Interceptors
[Fact]
public async Task AuditInterceptor_CreatesAuditLog_OnInsert()
{
// Arrange
var mockUser = new Mock<ICurrentUserService>();
mockUser.Setup(u => u.UserId).Returns("test-user");
var interceptor = new AuditInterceptor(mockUser.Object);
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase("TestDb")
.AddInterceptors(interceptor)
.Options;
await using var context = new AppDbContext(options);
// Act
var product = new Product { Name = "Test", Price = 100 };
context.Products.Add(product);
await context.SaveChangesAsync();
// Assert
var auditLogs = await context.Set<AuditLog>().ToListAsync();
Assert.Single(auditLogs);
Assert.Equal("Product", auditLogs[0].EntityName);
Assert.Equal("Added", auditLogs[0].Action);
Assert.Equal("test-user", auditLogs[0].UserId);
}
Interview Questions
Q: What are EF Core interceptors?
A: Interceptors hook into EF Core's pipeline to intercept operations like queries, SaveChanges, and connections. Use them for logging, auditing, performance monitoring, and modifying behavior.
Q: Name three types of interceptors.
A:
- DbCommandInterceptor: Intercept SQL commands (queries)
- SaveChangesInterceptor: Intercept SaveChanges operations
- DbConnectionInterceptor: Intercept database connections
Q: How would you log slow queries?
A: Use DbCommandInterceptor and override ReaderExecutedAsync to check eventData.Duration. Log warning if duration exceeds threshold.
Q: How do you register interceptors?
A: Add to DbContext configuration:
options.UseSqlServer(conn).AddInterceptors(new MyInterceptor());