Skip to main content

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:

  1. DbCommandInterceptor: Intercept SQL commands (queries)
  2. SaveChangesInterceptor: Intercept SaveChanges operations
  3. 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());

Additional Resources