Chapter 18 · Diagnostics, Logging, Interceptors, Metrics, and Observability

Command, Connection, Transaction, SaveChanges, and Materialization Interceptors

Use EF Core interceptors only where interception is actually required: observe commands, connections, transactions, SaveChanges, and materialization; preserve async behavior, avoid recursive side effects, and understand suppression/replacement and interceptor lifetime risks.

Advanced150–190 minutesinterceptor behavior labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 mandatory baselinedotnet-ef 10.0.11 · SDK 10.0.400Observability reviewed: August 2026

Learning outcomes

01

Distinguish interceptors from logging/diagnostic listeners: interceptors can modify, suppress, or replace EF operations.

02

Implement representative command, connection, transaction, SaveChanges, and materialization interception using EF Core 10 APIs.

03

Preserve sync/async behavior and avoid blocking I/O or recursive EF calls inside interceptor callbacks.

04

Reason about interceptor lifetime and state so pooled contexts or singleton materialization interceptors do not leak request-specific data.

05

Use observation-only interceptors for timing/auditing where appropriate and reserve suppression/replacement for narrow, tested scenarios.

06

Verify interceptor effects with logs/SQL/tracker/database evidence rather than assuming callback invocation implies correctness.

1. Interception is not another logging API

EF Core logging and diagnostic listeners are designed primarily to observe. An interceptor participates in the EF operation pipeline and may inspect, modify, suppress, or replace operations. That capability is useful for auditing, command customization, connection initialization, transaction coordination, SaveChanges policies, and materialization hooks. It also means a poorly designed interceptor can change application behavior globally and invisibly.

The exact contracts covered here are IDbCommandInterceptor, IDbConnectionInterceptor, IDbTransactionInterceptor, ISaveChangesInterceptor, and IMaterializationInterceptor. EF Core also provides convenience base classes such as DbCommandInterceptor, DbConnectionInterceptor, DbTransactionInterceptor, and SaveChangesInterceptor. The examples derive from those base classes when only a subset of callbacks is required, while materialization is shown through the interface directly.

Frozen lab baseline

Mandatory baseline: .NET runtime 10.0.11, SDK 10.0.400, Microsoft.EntityFrameworkCore / Design / Sqlite and dotnet-ef 10.0.11. SQLite is the free local database. EF Core 11 preview APIs are out of scope. OpenTelemetry core/hosting 1.18.0 may be used for an optional console telemetry path; OpenTelemetry.Instrumentation.EntityFrameworkCore 1.18.0-beta.1 remains prerelease/beta and is never required for the mandatory labs.

Default rule for this chapter

If you only need to record an event, prefer logging/metrics/diagnostic listeners. Add an interceptor only when the callback boundary itself is required or when a tested policy must affect EF behavior.

2. Register interceptors explicitly on configured contexts

csharp · registration through AddDbContext
builder.Services.AddSingleton<CommandObservationInterceptor>();builder.Services.AddSingleton<ConnectionObservationInterceptor>();builder.Services.AddSingleton<TransactionObservationInterceptor>();builder.Services.AddSingleton<SaveObservationInterceptor>();builder.Services.AddSingleton<MaterializationObservationInterceptor>();builder.Services.AddDbContext<ServiceHubContext>((sp, options) =>{    options.UseSqlite(connectionString);    options.AddInterceptors(        sp.GetRequiredService<CommandObservationInterceptor>(),        sp.GetRequiredService<ConnectionObservationInterceptor>(),        sp.GetRequiredService<TransactionObservationInterceptor>(),        sp.GetRequiredService<SaveObservationInterceptor>(),        sp.GetRequiredService<MaterializationObservationInterceptor>());});

Stateless observation interceptors can often be singleton services. A stateful auditing interceptor may need a different lifetime or must store state by context/operation safely. IMaterializationInterceptor is a singleton interceptor contract: never inject mutable request-specific state into it casually. Context pooling from Chapter 17 makes lifetime discipline even more important.

Ordering is behavior. When several application interceptors are supplied to AddInterceptors(...), treat your explicit registration order and any interaction between them as part of the application contract and integration-test it. Do not make correctness depend on undocumented relative ordering against provider/internal services; prefer independent observation interceptors and narrowly scoped mutations.

3. Command interception: observe duration and failure without logging parameter payloads

csharp · representative command interceptor
public sealed class CommandObservationInterceptor : DbCommandInterceptor{    private readonly ILogger<CommandObservationInterceptor> _logger;    public CommandObservationInterceptor(        ILogger<CommandObservationInterceptor> logger)        => _logger = logger;    public override DbDataReader ReaderExecuted(        DbCommand command,        CommandExecutedEventData eventData,        DbDataReader result)    {        _logger.LogInformation(            "EF reader completed in {DurationMs} ms; CommandId={CommandId}; TraceId={TraceId}",            eventData.Duration.TotalMilliseconds,            eventData.CommandId,            Activity.Current?.TraceId);        return result;    }    public override void CommandFailed(        DbCommand command,        CommandErrorEventData eventData)        => _logger.LogWarning(            eventData.Exception,            "EF command failed; CommandId={CommandId}; TraceId={TraceId}",            eventData.CommandId,            Activity.Current?.TraceId);}

The interceptor deliberately does not serialize command.Parameters. If a stable query identifier is needed, use a query tag or application operation ID. Command callbacks exist for reader, scalar, and non-query paths; one override does not represent all database work.

4. Connection interception: observe lifecycle, not “pool size”

csharp · connection-open observation
public sealed class ConnectionObservationInterceptor : DbConnectionInterceptor{    private readonly ILogger<ConnectionObservationInterceptor> _logger;    public ConnectionObservationInterceptor(        ILogger<ConnectionObservationInterceptor> logger)        => _logger = logger;    public override void ConnectionOpened(        DbConnection connection,        ConnectionEndEventData eventData)        => _logger.LogDebug(            "EF connection opened; Db={Database}; DurationMs={DurationMs}; TraceId={TraceId}",            connection.Database,            eventData.Duration.TotalMilliseconds,            Activity.Current?.TraceId);}

An open event does not tell you driver-pool occupancy or database-session saturation. Chapter 17 already separated DbContext pooling, ADO.NET connection pooling, and server sessions. Use provider/driver/database metrics for pool pressure.

5. Transaction interception: keep callbacks side-effect-safe

csharp · transaction outcome observation
public sealed class TransactionObservationInterceptor : DbTransactionInterceptor{    private readonly ILogger<TransactionObservationInterceptor> _logger;    public TransactionObservationInterceptor(        ILogger<TransactionObservationInterceptor> logger)        => _logger = logger;    public override void TransactionCommitted(        DbTransaction transaction,        TransactionEndEventData eventData)        => _logger.LogInformation(            "EF transaction committed; TransactionId={TransactionId}; TraceId={TraceId}",            eventData.TransactionId,            Activity.Current?.TraceId);    public override void TransactionRolledBack(        DbTransaction transaction,        TransactionEndEventData eventData)        => _logger.LogWarning(            "EF transaction rolled back; TransactionId={TransactionId}; TraceId={TraceId}",            eventData.TransactionId,            Activity.Current?.TraceId);}

Do not publish a message to an external broker inside TransactionCommitted and assume exactly-once delivery. A crash can happen after commit and before publish, or the external call can fail. Chapter 12's transactional outbox remains the safer bridge between local database commit and external delivery.

6. SaveChanges interception: auditing is reasonable; remote I/O before commit is not

csharp · observation-only SaveChanges interceptor
public sealed class SaveObservationInterceptor : SaveChangesInterceptor{    private readonly ILogger<SaveObservationInterceptor> _logger;    public SaveObservationInterceptor(ILogger<SaveObservationInterceptor> logger)        => _logger = logger;    public override InterceptionResult<int> SavingChanges(        DbContextEventData eventData,        InterceptionResult<int> result)    {        var changed = eventData.Context?.ChangeTracker            .Entries()            .Count(e => e.State is EntityState.Added                or EntityState.Modified                or EntityState.Deleted) ?? 0;        _logger.LogDebug("SaveChanges starting; ChangedEntries={ChangedEntries}", changed);        return result;    }    public override int SavedChanges(        SaveChangesCompletedEventData eventData,        int result)    {        _logger.LogInformation("SaveChanges completed; Rows={Rows}", result);        return result;    }}

For audit timestamps, Chapter 12 showed an interceptor/override that changes tracked shadow values before command generation. Keep mutation narrowly scoped and deterministic. Avoid invoking another SaveChanges on the same context from inside a SaveChanges interceptor—that is direct recursion.

7. Materialization interception runs as EF creates query results

csharp · safe materialization counter
public sealed class MaterializationObservationInterceptor : IMaterializationInterceptor{    private long _workOrders;    public object InitializedInstance(        MaterializationInterceptionData materializationData,        object entity)    {        if (entity is WorkOrder)        {            Interlocked.Increment(ref _workOrders);        }        return entity;    }    public long WorkOrdersMaterialized => Interlocked.Read(ref _workOrders);}

This interceptor is intentionally stateless with respect to users/requests; the counter is process-wide. Materialization hooks can inject non-mapped services or normalize instances, but that changes object construction semantics. If the domain needs behavior, prefer explicit constructors/factories from Chapter 06 before hiding it in a global interceptor.

8. Suppression/replacement is powerful enough to corrupt semantics

Command interceptors can return an InterceptionResult<T> that suppresses the real provider operation and supplies a replacement result. That is appropriate for a few infrastructure scenarios such as sophisticated caching, but a wrong result shape can bypass database constraints, return stale data, or make tests pass without executing SQL.

csharp · conceptual anti-pattern: suppress a write because tracing is degraded
// DO NOT do this:// if (telemetryBackendIsDown)//     return InterceptionResult<int>.SuppressWithResult(1);// Observability failure must not pretend a database write succeeded.

Telemetry should fail open or be buffered according to its own policy; it should not become part of the correctness path unless the business requirement explicitly demands it.

9. Async interception must remain async-safe

EF exposes sync and async interceptor callbacks. If an interceptor must perform asynchronous I/O, implement the corresponding async callback and honor its cancellation token. Do not call .Result or .Wait() on asynchronous telemetry work inside an EF async pipeline. Better still, keep interceptors free of remote I/O and enqueue bounded local telemetry where loss semantics are acceptable.

One DbContext is still not thread-safe

An interceptor does not make concurrent EF operations safe. Never start parallel work against the same context from inside a callback.

10. Failure case: the audit interceptor uses the same context to write audit rows

SavingChanges sees a modified WorkOrder and calls context.AuditRows.Add(...); context.SaveChanges(). The interceptor recursively re-enters itself and can produce recursion, duplicate events, invalid state, or transaction surprises.

Repair: either stamp values on the same tracked graph before command generation, add an outbox/audit entity to the same unit of work without recursively saving, or use a separate well-defined audit store with explicit consistency semantics. Chapter 12's outbox lesson is the preferred pattern when external publication is required.

11. Mandatory lab: observe without changing correctness

  1. Register all five representative interceptors against the disposable ServiceHub SQLite context.
  2. Run one read, one SaveChanges, and one explicit transaction.
  3. Verify command, connection, transaction, SaveChanges, and materialization events appear with the same trace ID where an application Activity exists.
  4. Verify no interceptor writes SQL parameter values or customer payloads to logs.
  5. Trigger one known database failure and verify CommandFailed observes the exception without suppressing it.
  6. Assert removing all interceptors does not change database results.
  7. Document interceptor DI lifetime and whether state is process-, context-, or operation-scoped.

12. Production judgment

Interceptors are cross-cutting code with a broad blast radius. Keep them small, observable, cancellation-aware, and covered by integration tests against the real provider. Prefer logging/metrics for passive telemetry; use interception for true pipeline policies. Never make telemetry availability control transaction correctness. The next lesson applies safer correlation primitives—query tags, command durations, timeouts, and actual database plans—to diagnose slow queries.

Check your understanding

  1. How are interceptors fundamentally different from logging?
  2. Why is a singleton materialization interceptor a bad place for mutable user-specific state?
  3. What is wrong with doing remote broker I/O inside TransactionCommitted?
  4. Can a command interceptor's duration replace a database execution plan?
  5. What should happen if the telemetry backend is unavailable?
  6. Why should interceptor integration tests run against the real relational provider?
Review the answers

1. They can modify, suppress, or replace EF operations, not merely observe them.

2. It can be reused across many contexts/requests and leak or race that state; keep it stateless or use safe process-wide state.

3. It is not atomically coupled to the database commit; crashes/failures create dual-write ambiguity. Use a transactional outbox when needed.

4. No. It is a client/provider-side timing signal and must be correlated with database-side evidence.

5. Observability failure should not fabricate database success; use an explicit telemetry degradation policy that preserves application correctness.

6. Suppression, transaction, command and materialization behavior depends on actual EF/provider/database pipeline semantics.

Authoritative references

Observability and diagnostics are version-, provider-, and topology-sensitive. Re-check these primary sources before standardizing a production telemetry contract.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n