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.
Learning outcomes
Distinguish interceptors from logging/diagnostic listeners: interceptors can modify, suppress, or replace EF operations.
Implement representative command, connection, transaction, SaveChanges, and materialization interception using EF Core 10 APIs.
Preserve sync/async behavior and avoid blocking I/O or recursive EF calls inside interceptor callbacks.
Reason about interceptor lifetime and state so pooled contexts or singleton materialization interceptors do not leak request-specific data.
Use observation-only interceptors for timing/auditing where appropriate and reserve suppression/replacement for narrow, tested scenarios.
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.
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.
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
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
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”
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
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
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
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.
// 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.
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
- Register all five representative interceptors against the disposable ServiceHub SQLite context.
- Run one read, one SaveChanges, and one explicit transaction.
- Verify command, connection, transaction, SaveChanges, and materialization events appear with the same trace ID where an application Activity exists.
- Verify no interceptor writes SQL parameter values or customer payloads to logs.
-
Trigger one known database failure and verify
CommandFailedobserves the exception without suppressing it. - Assert removing all interceptors does not change database results.
- 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
- How are interceptors fundamentally different from logging?
- Why is a singleton materialization interceptor a bad place for mutable user-specific state?
- What is wrong with doing remote broker I/O inside TransactionCommitted?
- Can a command interceptor's duration replace a database execution plan?
- What should happen if the telemetry backend is unavailable?
- 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.
- Interceptors - EF Core — registration, command, SaveChanges and materialization interception
- DbCommandInterceptor API - EF Core 10 — command interception callbacks and duration/failure event data
- DbConnectionInterceptor API - EF Core 10 — connection lifecycle interception
- DbTransactionInterceptor API - EF Core 10 — transaction callbacks
- IMaterializationInterceptor API - EF Core 10 — entity creation/initialization interception
- SaveChanges interception - EF Core — SaveChanges interception and audit pattern guidance