Logging, Interceptors and Diagnostics

See the SQL EF Core sends, keep sensitive data out of logs, stamp audit fields with a SaveChangesInterceptor and flag slow commands.

Syntaxbuilder.Services.AddDbContext<ShopDb>((sp, o) => o .UseNpgsql(cs) .AddInterceptors(sp.GetRequiredService<AuditInterceptor>())); options.EnableSensitiveDataLogging(); // Development only

When a context is registered with AddDbContext, EF Core logs through the host's ILoggerFactory, so its output follows your normal logging configuration. Outside DI, such as in a console tool or a test, LogTo(Console.WriteLine, LogLevel.Information) writes the same events to any delegate and can filter by event id.

Log categories

CategoryWhat it showsProduction level
Microsoft.EntityFrameworkCore.Database.CommandEach executed command with duration and parameter names (Information); failures (Error)Warning; Information only while investigating
Microsoft.EntityFrameworkCore.Database.TransactionBegin, commit, rollback, savepoints (Debug)Warning
Microsoft.EntityFrameworkCore.QueryTranslation warnings such as multiple collection includesWarning
Microsoft.EntityFrameworkCore.InfrastructureContext initialisation, provider detailsWarning

Sensitive data

By default parameter values are logged as '?'. EnableSensitiveDataLogging() logs the values and puts key values in exception messages, which writes customers' emails, addresses and tokens into the log pipeline. Enable it only in Development, behind an environment check. EnableDetailedErrors() gives more precise errors when a value cannot be materialised (a NULL in a non-nullable column) at a small cost per query; it is fine in development and test.

Turning warnings into errors

ConfigureWarnings(w => w.Throw(...)) makes a warning throw at the point the query is compiled. Good candidates in development: RelationalEventId.MultipleCollectionIncludeWarning and CoreEventId.RowLimitingOperationWithoutOrderByWarning (Skip or Take without OrderBy returns rows in an arbitrary order).

Interceptors

Interface (base class)Typical use
ISaveChangesInterceptor (SaveChangesInterceptor)Audit fields, soft delete, writing outbox rows
IDbCommandInterceptor (DbCommandInterceptor)Slow command logging, query hints, inspecting SQL
IDbConnectionInterceptorSession setup when a connection opens
IDbTransactionInterceptorObserving commits and rollbacks

Register them with AddInterceptors. Use the AddDbContext((sp, options) => ...) overload to resolve them from DI. With AddDbContextPool the options are built once and shared by every pooled context, and a stateless interceptor is reused safely either way, so register interceptors as singletons: no per-request fields, thread-safe, and only singleton dependencies such as TimeProvider or IHttpContextAccessor. Read request data through the accessor at call time instead of capturing it.

Production failure modes

  • Overriding only one path. EF calls SavingChanges for SaveChanges() and SavingChangesAsync for SaveChangesAsync(); override both or some writes skip your audit logic.
  • Bulk statements. ExecuteUpdate and ExecuteDelete never reach a SaveChangesInterceptor. Command interceptors still see them.
  • Logging every command at Information. One request with 15 queries writes 15 multi-line entries; at a few hundred requests per second the log bill grows faster than the database bill. Log slow commands only.
  • Finding a query in the database. TagWith("orders: customer history") adds a SQL comment visible in pg_stat_statements and Query Store, linking a slow statement back to its code.

Example

Example · csharp
using System.Data.Common;
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<AuditInterceptor>();
builder.Services.AddSingleton<SlowCommandInterceptor>();

builder.Services.AddDbContext<ShopDb>((sp, options) =>
{
    options.UseNpgsql(builder.Configuration.GetConnectionString("Shop"))
        .AddInterceptors(
            sp.GetRequiredService<AuditInterceptor>(),
            sp.GetRequiredService<SlowCommandInterceptor>());

    if (builder.Environment.IsDevelopment())
    {
        options.EnableSensitiveDataLogging().EnableDetailedErrors();
    }
});

var app = builder.Build();

app.MapPost("/customers", async (NewCustomer body, ShopDb db, CancellationToken ct) =>
{
    var customer = new Customer { Email = body.Email, Name = body.Name };
    db.Customers.Add(customer);
    await db.SaveChangesAsync(ct);
    return TypedResults.Created($"/customers/{customer.Id}", new { customer.Id, customer.CreatedAt });
});

app.Run();

public record NewCustomer(string Email, string Name);

public interface IAudited
{
    DateTimeOffset CreatedAt { get; set; }
    DateTimeOffset UpdatedAt { get; set; }
    string UpdatedBy { get; set; }
}

public class Customer : IAudited
{
    public int Id { get; set; }
    public string Email { get; set; } = "";
    public string Name { get; set; } = "";
    public DateTimeOffset CreatedAt { get; set; }
    public DateTimeOffset UpdatedAt { get; set; }
    public string UpdatedBy { get; set; } = "";
}

public sealed class AuditInterceptor(TimeProvider clock, IHttpContextAccessor http) : SaveChangesInterceptor
{
    public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
    {
        Stamp(eventData.Context);
        return result;
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
    {
        Stamp(eventData.Context);
        return ValueTask.FromResult(result);
    }

    private void Stamp(DbContext? context)
    {
        if (context is null) return;
        var now = clock.GetUtcNow();
        var user = http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";

        foreach (var entry in context.ChangeTracker.Entries<IAudited>())
        {
            if (entry.State == EntityState.Added)
            {
                entry.Entity.CreatedAt = now;
            }
            else if (entry.State == EntityState.Modified)
            {
                // A client cannot rewrite history by sending a different CreatedAt.
                entry.Property(e => e.CreatedAt).IsModified = false;
            }
            else
            {
                continue;
            }
            entry.Entity.UpdatedAt = now;
            entry.Entity.UpdatedBy = user;
        }
    }
}

public sealed partial class SlowCommandInterceptor(ILogger<SlowCommandInterceptor> logger) : DbCommandInterceptor
{
    private static readonly TimeSpan Threshold = TimeSpan.FromMilliseconds(500);

    public override DbDataReader ReaderExecuted(DbCommand command, CommandExecutedEventData eventData, DbDataReader result)
    {
        Check(command, eventData);
        return result;
    }

    public override ValueTask<DbDataReader> ReaderExecutedAsync(
        DbCommand command, CommandExecutedEventData eventData, DbDataReader result, CancellationToken cancellationToken = default)
    {
        Check(command, eventData);
        return ValueTask.FromResult(result);
    }

    // ExecuteUpdate, ExecuteDelete and ExecuteSql run as non-queries; aggregates can run as scalars.
    public override int NonQueryExecuted(DbCommand command, CommandExecutedEventData eventData, int result)
    {
        Check(command, eventData);
        return result;
    }

    public override ValueTask<int> NonQueryExecutedAsync(
        DbCommand command, CommandExecutedEventData eventData, int result, CancellationToken cancellationToken = default)
    {
        Check(command, eventData);
        return ValueTask.FromResult(result);
    }

    public override object? ScalarExecuted(DbCommand command, CommandExecutedEventData eventData, object? result)
    {
        Check(command, eventData);
        return result;
    }

    public override ValueTask<object?> ScalarExecutedAsync(
        DbCommand command, CommandExecutedEventData eventData, object? result, CancellationToken cancellationToken = default)
    {
        Check(command, eventData);
        return ValueTask.FromResult(result);
    }

    private void Check(DbCommand command, CommandExecutedEventData eventData)
    {
        if (eventData.Duration > Threshold)
        {
            // CommandText only: parameter values stay out of the log.
            LogSlowCommand(logger, eventData.Duration.TotalMilliseconds, command.CommandText);
        }
    }

    [LoggerMessage(Level = LogLevel.Warning, Message = "Slow SQL command took {ElapsedMs} ms: {CommandText}")]
    private static partial void LogSlowCommand(ILogger logger, double elapsedMs, string commandText);
}

public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Customer> Customers => Set<Customer>();
}

When to use it

  • Stamp CreatedAt, UpdatedAt and UpdatedBy on every audited entity from one SaveChangesInterceptor instead of in each handler.
  • Log any SQL command slower than 500 ms with its text but without parameter values, so slow queries are visible without leaking data.
  • Raise EF command logging to Information during an incident by editing the reloaded appsettings ConfigMap, with no restart, then lower it again.

More examples

Production logging levels for EF Core

This is a sensible <code>appsettings.json</code> baseline: EF Core stays quiet unless something is wrong. To investigate an incident, raise <code>Microsoft.EntityFrameworkCore.Database.Command</code> to <code>Information</code> without a restart through configuration reload: JSON files are loaded with <code>reloadOnChange</code>, so editing appsettings mounted from a ConfigMap (or a refreshed Azure App Configuration key) takes effect in the running process; lower it again afterwards. An environment variable such as <code>Logging__LogLevel__Microsoft.EntityFrameworkCore.Database.Command</code> works only after a restart, which on a Deployment rolls every pod. Slow-command logging from an interceptor covers the everyday case without the volume.

Example · json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      // Warnings (bad query shapes) and errors (failed commands) only.
      "Microsoft.EntityFrameworkCore": "Warning",
      // Failed commands are logged at Error in this category, with the SQL text.
      "Microsoft.EntityFrameworkCore.Database.Command": "Warning",
      // Model validation warnings appear once at start-up; keep them.
      "Microsoft.EntityFrameworkCore.Model.Validation": "Warning"
    }
  }
}

LogTo, warnings as errors and query tags in a test harness

<code>LogTo</code> with an event id list prints only the events you care about, which keeps test output readable. The tagged query's SQL starts with <code>-- orders: recent for customer</code>, the same comment a DBA sees next to the statement in <code>pg_stat_statements</code>. The configured warning turns an unordered <code>Take</code> into an exception at query compilation, so the bug fails a test instead of returning a different page on each call in production.

Example · csharp
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();

var options = new DbContextOptionsBuilder<ShopDb>()
    .UseSqlite(connection)
    // Only the executed-command event, not every EF event at Information.
    .LogTo(Console.WriteLine, [RelationalEventId.CommandExecuted])
    .ConfigureWarnings(w => w.Throw(CoreEventId.RowLimitingOperationWithoutOrderByWarning))
    .Options;

await using var db = new ShopDb(options);
await db.Database.EnsureCreatedAsync();
db.Orders.Add(new Order { CustomerId = 7, Total = 129.50m });
await db.SaveChangesAsync();

var recent = await db.Orders
    .TagWith("orders: recent for customer")
    .Where(o => o.CustomerId == 7)
    .OrderByDescending(o => o.Id)
    .Take(10)
    .ToListAsync();
Console.WriteLine($"Loaded {recent.Count} order(s)");

try
{
    // Take without OrderBy now throws instead of returning an arbitrary page.
    await db.Orders.Take(10).ToListAsync();
}
catch (InvalidOperationException ex)
{
    // "An error was generated for warning '...RowLimitingOperationWithoutOrderByWarning': ..."
    Console.WriteLine(ex.Message);
}

public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public decimal Total { get; set; }
}

public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
}

Testing the audit interceptor with a fake clock

Because the interceptor takes <code>TimeProvider</code> instead of reading <code>DateTimeOffset.UtcNow</code>, the test controls time exactly: <code>FakeTimeProvider.Advance</code> moves the clock three hours between the insert and the update. This test overrides only the async path because it only calls <code>SaveChangesAsync</code>; the production interceptor in the main example overrides both. SQLite stores <code>DateTimeOffset</code> as text, which is fine for asserting stamped values but not for range queries.

Example · csharp
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Time.Testing;
using Xunit;

public class Product
{
    public int Id { get; set; }
    public string Sku { get; set; } = "";
    public DateTimeOffset CreatedAt { get; set; }
    public DateTimeOffset UpdatedAt { get; set; }
}

public sealed class StampInterceptor(TimeProvider clock) : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
    {
        var now = clock.GetUtcNow();
        foreach (var entry in eventData.Context!.ChangeTracker.Entries<Product>())
        {
            if (entry.State == EntityState.Added) entry.Entity.CreatedAt = now;
            if (entry.State is EntityState.Added or EntityState.Modified) entry.Entity.UpdatedAt = now;
        }
        return ValueTask.FromResult(result);
    }
}

public class CatalogDb(DbContextOptions<CatalogDb> options) : DbContext(options)
{
    public DbSet<Product> Products => Set<Product>();
}

public class StampInterceptorTests
{
    [Fact]
    public async Task Update_moves_UpdatedAt_but_keeps_CreatedAt()
    {
        var ct = TestContext.Current.CancellationToken;
        var clock = new FakeTimeProvider(new DateTimeOffset(2026, 3, 2, 9, 0, 0, TimeSpan.Zero));
        await using var connection = new SqliteConnection("Data Source=:memory:");
        await connection.OpenAsync(ct);
        var options = new DbContextOptionsBuilder<CatalogDb>()
            .UseSqlite(connection)
            .AddInterceptors(new StampInterceptor(clock))
            .Options;

        await using var db = new CatalogDb(options);
        await db.Database.EnsureCreatedAsync(ct);
        var product = new Product { Sku = "KETTLE-BLUE" };
        db.Products.Add(product);
        await db.SaveChangesAsync(ct);

        clock.Advance(TimeSpan.FromHours(3));
        product.Sku = "KETTLE-BLUE-17";
        await db.SaveChangesAsync(ct);

        Assert.Equal(new DateTimeOffset(2026, 3, 2, 9, 0, 0, TimeSpan.Zero), product.CreatedAt);
        Assert.Equal(new DateTimeOffset(2026, 3, 2, 12, 0, 0, TimeSpan.Zero), product.UpdatedAt);
    }
}

Discussion

  • Be the first to comment on this lesson.