Compartilhar via


Interceptores

Os interceptores do EF Core (Entity Framework Core) permitem a interceptação, a modificação e/ou a supressão de operações do produto. Isso inclui operações de banco de dados de nível baixo, como executar um comando, bem como operações de nível superior, como chamadas para SaveChanges.

Os interceptores são diferentes do registro em log e diagnóstico, pois permitem a modificação ou supressão da operação que está sendo interceptada. Registro em log simples ou Microsoft.Extensions.Logging são melhores opções para registro em log.

Os interceptores são registrados por instância de DbContext quando o contexto é configurado. Use um ouvinte de diagnóstico para obter as mesmas informações, mas para todas as instâncias de DbContext no processo.

Registrar interceptores

Os interceptores são registrados usando AddInterceptors ao configurar uma instância do DbContext. Isso geralmente é feito como uma substituição de DbContext.OnConfiguring. Por exemplo:

public class ExampleContext : BlogsContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.AddInterceptors(new TaggedQueryCommandInterceptor());
}

Como alternativa, AddInterceptors pode ser chamado como parte de AddDbContext ou ao criar uma instância DbContextOptions para transmissão ao construtor do DbContext.

Dica

OnConfiguring ainda é chamado quando AddDbContext é usado ou ao transmitir uma instância DbContextOptions para o construtor do DbContext. Isso o torna o local ideal para aplicar a configuração de contexto, independentemente de como o DbContext é construído.

Os interceptores geralmente são sem estado, o que significa que uma única instância de interceptor pode ser usada para todas as instâncias do DbContext. Por exemplo:

public class TaggedQueryCommandInterceptorContext : BlogsContext
{
    private static readonly TaggedQueryCommandInterceptor _interceptor
        = new TaggedQueryCommandInterceptor();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.AddInterceptors(_interceptor);
}

Cada instância de interceptor deve implementar uma ou mais interfaces derivadas de IInterceptor. Cada instância só deve ser registrada uma vez, mesmo que implemente muitas interfaces de interceptação. O EF Core roteará eventos para cada interface conforme apropriado.

Interceptação de banco de dados

Observação

A interceptação de banco de dados só está disponível para provedores de banco de dados relacionais.

A interceptação de banco de dados de nível baixo é dividida nas três interfaces mostradas na tabela a seguir.

Interceptor Interceptação de operações de banco de dados
IDbCommandInterceptor Criar comandos
Executar comandos
Falhas de comando
Descartar o DbDataReader do comando
IDbConnectionInterceptor Abrir e encerrar conexões
Falhas de conexão
IDbTransactionInterceptor Criar transações
Usar transações existentes
Confirmar transações
Reverter transações
Criar e usar pontos de salvamento
Falhas de transação

As classes base DbCommandInterceptor, DbConnectionInterceptor e DbTransactionInterceptor contêm implementações sem operações para cada método na interface correspondente. Use as classes base a fim de evitar a necessidade de implementar métodos de interceptação não utilizados.

Os métodos em cada tipo de interceptor são fornecidos em pares, com o primeiro sendo chamado antes do início da operação de banco de dados e o segundo após a conclusão dela. Por exemplo, DbCommandInterceptor.ReaderExecuting é chamado antes da execução de uma consulta e DbCommandInterceptor.ReaderExecuted é chamado depois do envio da consulta para o banco de dados.

Cada par de métodos tem variações assíncronas e síncronas. Isso permite que uma E/S assíncrona, como a solicitação de um token de acesso, ocorra como parte da interceptação de uma operação de banco de dados assíncrona.

Exemplo: interceptação de comando para adicionar dicas de consulta

Dica

É possível baixar o exemplo do interceptor de comandos no GitHub.

Um IDbCommandInterceptor pode ser usado para modificar o SQL antes do envio ao banco de dados. Este exemplo mostra como modificar o SQL para incluir uma dica de consulta.

Geralmente, a parte mais complicada da interceptação consiste em determinar quando o comando corresponde à consulta que precisa ser modificada. Analisar o SQL é uma opção, mas isso normalmente é insuficiente. Outra opção é usar marcas de consulta do EF Core para marcar cada consulta que deve ser modificada. Por exemplo:

var blogs1 = context.Blogs.TagWith("Use hint: robust plan").ToList();

Essa marca pode ser detectada no interceptor, pois é sempre incluída como um comentário na primeira linha do texto do comando. Ao detectar a marca, o SQL de consulta é modificado para adicionar a dica apropriada:

public class TaggedQueryCommandInterceptor : DbCommandInterceptor
{
    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result)
    {
        ManipulateCommand(command);

        return result;
    }

    public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result,
        CancellationToken cancellationToken = default)
    {
        ManipulateCommand(command);

        return new ValueTask<InterceptionResult<DbDataReader>>(result);
    }

    private static void ManipulateCommand(DbCommand command)
    {
        if (command.CommandText.StartsWith("-- Use hint: robust plan", StringComparison.Ordinal))
        {
            command.CommandText += " OPTION (ROBUST PLAN)";
        }
    }
}

Aviso:

  • O interceptor herda de DbCommandInterceptor para evitar a necessidade de implementar todos os métodos na interface.
  • O interceptor implementa métodos síncronos e assíncronos. Isso garante que a mesma dica de consulta seja aplicada a consultas assíncronas e síncronas.
  • O interceptor implementa os métodos Executing que são chamados pelo EF Core com o SQL gerado antes do envio ao banco de dados. Isso é diferente no caso dos métodos Executed, que são chamados após o retorno da chamada do banco de dados.

A execução do código neste exemplo gera o seguinte quando uma consulta é marcada:

-- Use hint: robust plan

SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b] OPTION (ROBUST PLAN)

Por outro lado, quando uma consulta não é marcada, ela é enviada ao banco de dados sem modificação:

SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]

Exemplo: interceptação de conexão para a autenticação do SQL do Azure usando o AAD

Dica

É possível baixar o exemplo do interceptor de conexão no GitHub.

Um IDbConnectionInterceptor pode ser usado a fim de manipular o DbConnection antes do uso dele para a conexão com o banco de dados. Isso pode ser usado para obter um token de acesso do AAD (Azure Active Directory). Por exemplo:

public class AadAuthenticationInterceptor : DbConnectionInterceptor
{
    public override InterceptionResult ConnectionOpening(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result)
        => throw new InvalidOperationException("Open connections asynchronously when using AAD authentication.");

    public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
    {
        var sqlConnection = (SqlConnection)connection;

        var provider = new AzureServiceTokenProvider();
        // Note: in some situations the access token may not be cached automatically the Azure Token Provider.
        // Depending on the kind of token requested, you may need to implement your own caching here.
        sqlConnection.AccessToken = await provider.GetAccessTokenAsync("https://database.windows.net/", null, cancellationToken);

        return result;
    }
}

Dica

O Microsoft.Data.SqlClient agora dá suporte à autenticação do AAD por meio da cadeia de conexão. Consulte SqlAuthenticationMethod para obter mais informações.

Aviso

Observe que o interceptor é gerado quando uma chamada de sincronização é feita para estabelecer a conexão. Isso ocorre porque não há nenhum método não assíncrono de obtenção do token de acesso e não há uma maneira universal e simples de chamar um método assíncrono em um contexto não assíncrono sem correr o risco de deadlock.

Aviso

Em algumas situações, o token de acesso pode não ser armazenado automaticamente em cache no provedor de token do Azure. Dependendo do tipo de token solicitado, talvez seja necessário implementar seu próprio cache aqui.

Exemplo: interceptação de comando avançada para cache

Os interceptores do EF Core podem fazer o seguinte:

  • Informar ao EF Core que ele deve suprimir a execução da operação que está sendo interceptada
  • Alterar o resultado da operação relatada ao EF Core

Este exemplo mostra um interceptor que usa esses recursos para se comportar como um cache primitivo de segundo nível. Os resultados da consulta armazenados em cache são retornados para uma consulta específica, o que evita uma viagem de ida e volta do banco de dados.

Aviso

Tome cuidado ao alterar o comportamento padrão do EF Core dessa maneira. O EF Core poderá se comportar de maneiras inesperadas se receber um resultado anormal que não possa ser processado corretamente. Além disso, este exemplo demonstra conceitos de interceptor. Ele não deve ser usado como um modelo para uma implementação robusta de cache de segundo nível.

Neste exemplo, o aplicativo executa frequentemente uma consulta para obter a "mensagem diária" mais recente:

async Task<string> GetDailyMessage(DailyMessageContext context)
    => (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;

Essa consulta é marcada para que possa ser facilmente detectada no interceptor. A ideia é consultar se há novas mensagens no banco de dados somente uma vez por dia. Em outras ocasiões, o aplicativo usará um resultado armazenado em cache. (O exemplo usa um atraso de 10 segundos para simular um novo dia.)

Estado do interceptor

Este interceptor é do tipo com estado: ele armazena a ID e o texto da mensagem diária mais recente que foi consultada, além da hora em que essa consulta foi executada. Devido a esse estado, também é necessário um bloqueio, pois o cache requer que o mesmo interceptor seja usado por diversas instâncias de contexto.

private readonly object _lock = new object();
private int _id;
private string _message;
private DateTime _queriedAt;

Antes da execução

No método Executing (ou seja, antes de fazer uma chamada de banco de dados), o interceptor detecta a consulta marcada e verifica se há um resultado armazenado em cache. Se houver, a consulta será suprimida e os resultados armazenados em cache serão usados.

public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
    DbCommand command,
    CommandEventData eventData,
    InterceptionResult<DbDataReader> result,
    CancellationToken cancellationToken = default)
{
    if (command.CommandText.StartsWith("-- Get_Daily_Message", StringComparison.Ordinal))
    {
        lock (_lock)
        {
            if (_message != null
                && DateTime.UtcNow < _queriedAt + new TimeSpan(0, 0, 10))
            {
                command.CommandText = "-- Get_Daily_Message: Skipping DB call; using cache.";
                result = InterceptionResult<DbDataReader>.SuppressWithResult(new CachedDailyMessageDataReader(_id, _message));
            }
        }
    }

    return new ValueTask<InterceptionResult<DbDataReader>>(result);
}

Observe como o código chama InterceptionResult<TResult>.SuppressWithResult e transmite um DbDataReader de substituição que contém os dados armazenados em cache. Em seguida, esse InterceptionResult é retornado, o que causa a supressão da execução da consulta. O leitor de substituição é usado alternativamente pelo EF Core como os resultados da consulta.

Esse interceptor também manipula o texto do comando. Essa manipulação não é necessária, mas melhora a clareza nas mensagens de log. O texto do comando não precisa ser um SQL válido, pois a consulta agora não será executada.

Após a execução

Se nenhuma mensagem armazenada em cache estiver disponível ou se ela tiver expirado, o código acima não suprimirá o resultado. Nesse caso, o EF Core executará a consulta normalmente. Em seguida, ele retornará ao método Executed do interceptor após a execução. Aqui, se o resultado ainda não for um leitor armazenado em cache, a nova ID da mensagem e a cadeia de caracteres serão extraídas do leitor real e armazenadas em cache a fim de estarem prontas para o próximo uso da consulta.

public override async ValueTask<DbDataReader> ReaderExecutedAsync(
    DbCommand command,
    CommandExecutedEventData eventData,
    DbDataReader result,
    CancellationToken cancellationToken = default)
{
    if (command.CommandText.StartsWith("-- Get_Daily_Message", StringComparison.Ordinal)
        && !(result is CachedDailyMessageDataReader))
    {
        try
        {
            await result.ReadAsync(cancellationToken);

            lock (_lock)
            {
                _id = result.GetInt32(0);
                _message = result.GetString(1);
                _queriedAt = DateTime.UtcNow;
                return new CachedDailyMessageDataReader(_id, _message);
            }
        }
        finally
        {
            await result.DisposeAsync();
        }
    }

    return result;
}

Demonstração

O exemplo do interceptor de cache contém um aplicativo de console simples que consulta mensagens diárias para testar o cache:

// 1. Initialize the database with some daily messages.
using (var context = new DailyMessageContext())
{
    await context.Database.EnsureDeletedAsync();
    await context.Database.EnsureCreatedAsync();

    context.AddRange(
        new DailyMessage { Message = "Remember: All builds are GA; no builds are RTM." },
        new DailyMessage { Message = "Keep calm and drink tea" });

    await context.SaveChangesAsync();
}

// 2. Query for the most recent daily message. It will be cached for 10 seconds.
using (var context = new DailyMessageContext())
{
    Console.WriteLine(await GetDailyMessage(context));
}

// 3. Insert a new daily message.
using (var context = new DailyMessageContext())
{
    context.Add(new DailyMessage { Message = "Free beer for unicorns" });

    await context.SaveChangesAsync();
}

// 4. Cached message is used until cache expires.
using (var context = new DailyMessageContext())
{
    Console.WriteLine(await GetDailyMessage(context));
}

// 5. Pretend it's the next day.
Thread.Sleep(10000);

// 6. Cache is expired, so the last message will not be queried again.
using (var context = new DailyMessageContext())
{
    Console.WriteLine(await GetDailyMessage(context));
}

async Task<string> GetDailyMessage(DailyMessageContext context)
    => (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;

Isso resulta na seguinte saída:

info: 10/15/2020 12:32:11.801 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      -- Get_Daily_Message

      SELECT "d"."Id", "d"."Message"
      FROM "DailyMessages" AS "d"
      ORDER BY "d"."Id" DESC
      LIMIT 1

Keep calm and drink tea

info: 10/15/2020 12:32:11.821 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='Free beer for unicorns' (Size = 22)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "DailyMessages" ("Message")
      VALUES (@p0);
      SELECT "Id"
      FROM "DailyMessages"
      WHERE changes() = 1 AND "rowid" = last_insert_rowid();

info: 10/15/2020 12:32:11.826 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      -- Get_Daily_Message: Skipping DB call; using cache.

Keep calm and drink tea

info: 10/15/2020 12:32:21.833 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      -- Get_Daily_Message

      SELECT "d"."Id", "d"."Message"
      FROM "DailyMessages" AS "d"
      ORDER BY "d"."Id" DESC
      LIMIT 1

Free beer for unicorns

Observe na saída do log que o aplicativo continua a usar a mensagem armazenada em cache até a expiração do tempo limite, quando o banco de dados é consultado novamente em busca de novas mensagens.

Interceptação de SaveChanges

Dica

É possível baixar o exemplo do interceptor de SaveChanges no GitHub.

Os pontos de interceptação SaveChanges e SaveChangesAsync são definidos pela interface ISaveChangesInterceptor. Quanto a outros interceptores, a classe base SaveChangesInterceptor com métodos sem operações é fornecida por conveniência.

Dica

Interceptores são poderosos. No entanto, em muitos casos, pode ser mais fácil substituir o método SaveChanges ou usar os eventos .NET para SaveChanges expostos no DbContext.

Exemplo: interceptação de SaveChanges para auditoria

SaveChanges pode ser interceptado para criar um registro de auditoria independente das alterações feitas.

Observação

Isso não é uma solução de auditoria robusta. Em vez disso, é um exemplo simplista usado para demonstrar os recursos de interceptação.

O contexto do aplicativo

O exemplo para auditoria usa um DbContext simples com blogs e postagens.

public class BlogsContext : DbContext
{
    private readonly AuditingInterceptor _auditingInterceptor = new AuditingInterceptor("DataSource=audit.db");

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .AddInterceptors(_auditingInterceptor)
            .UseSqlite("DataSource=blogs.db");

    public DbSet<Blog> Blogs { get; set; }
}

public class Blog
{
    public int Id { get; set; }
    public string Name { get; set; }

    public ICollection<Post> Posts { get; } = new List<Post>();
}

public class Post
{
    public int Id { get; set; }
    public string Title { get; set; }

    public Blog Blog { get; set; }
}

Observe que uma nova instância do interceptor está registrada para cada instância do DbContext. Isso ocorre porque o interceptor de auditoria contém o estado vinculado à instância de contexto atual.

O contexto da auditoria

O exemplo também contém um segundo DbContext e um modelo usados para o banco de dados de auditoria.

public class AuditContext : DbContext
{
    private readonly string _connectionString;

    public AuditContext(string connectionString)
    {
        _connectionString = connectionString;
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.UseSqlite(_connectionString);

    public DbSet<SaveChangesAudit> SaveChangesAudits { get; set; }
}

public class SaveChangesAudit
{
    public int Id { get; set; }
    public Guid AuditId { get; set; }
    public DateTime StartTime { get; set; }
    public DateTime EndTime { get; set; }
    public bool Succeeded { get; set; }
    public string ErrorMessage { get; set; }

    public ICollection<EntityAudit> Entities { get; } = new List<EntityAudit>();
}

public class EntityAudit
{
    public int Id { get; set; }
    public EntityState State { get; set; }
    public string AuditMessage { get; set; }

    public SaveChangesAudit SaveChangesAudit { get; set; }
}

O interceptor

A ideia geral da auditoria com o interceptor é a seguinte:

  • Uma mensagem de auditoria é criada no início do SaveChanges e é gravada no banco de dados de auditoria
  • A permissão para continuar é fornecida ao SaveChanges
  • Se ele for bem-sucedido, a mensagem de auditoria será atualizada para indicar o sucesso
  • Se ocorrer uma falha, a mensagem de auditoria será atualizada para indicar a falha

A primeira fase é abordada antes de qualquer alteração ser enviada ao banco de dados usando substituições de ISaveChangesInterceptor.SavingChanges e ISaveChangesInterceptor.SavingChangesAsync.

public async ValueTask<InterceptionResult<int>> SavingChangesAsync(
    DbContextEventData eventData,
    InterceptionResult<int> result,
    CancellationToken cancellationToken = default)
{
    _audit = CreateAudit(eventData.Context);

    using var auditContext = new AuditContext(_connectionString);

    auditContext.Add(_audit);
    await auditContext.SaveChangesAsync();

    return result;
}

public InterceptionResult<int> SavingChanges(
    DbContextEventData eventData,
    InterceptionResult<int> result)
{
    _audit = CreateAudit(eventData.Context);

    using var auditContext = new AuditContext(_connectionString);
    auditContext.Add(_audit);
    auditContext.SaveChanges();

    return result;
}

Substituir métodos síncronos e assíncronos garante que a auditoria ocorra independentemente de SaveChanges ou SaveChangesAsync serem chamados. Observe também que a sobrecarga assíncrona é capaz de executar por conta própria a E/S assíncrona sem bloqueio no banco de dados de auditoria. Talvez você queira utilizar o método SavingChanges síncrono para garantir que todas as E/S do banco de dados sejam assíncronas. Isso requer que o aplicativo sempre chame SaveChangesAsync e nunca SaveChanges.

A mensagem de auditoria

Cada método do interceptor tem um parâmetro eventData que fornece informações contextuais sobre o evento que está sendo interceptado. Nesse caso, o DbContext do aplicativo atual é incluído nos dados do evento, que são usados para criar uma mensagem de auditoria.

private static SaveChangesAudit CreateAudit(DbContext context)
{
    context.ChangeTracker.DetectChanges();

    var audit = new SaveChangesAudit { AuditId = Guid.NewGuid(), StartTime = DateTime.UtcNow };

    foreach (var entry in context.ChangeTracker.Entries())
    {
        var auditMessage = entry.State switch
        {
            EntityState.Deleted => CreateDeletedMessage(entry),
            EntityState.Modified => CreateModifiedMessage(entry),
            EntityState.Added => CreateAddedMessage(entry),
            _ => null
        };

        if (auditMessage != null)
        {
            audit.Entities.Add(new EntityAudit { State = entry.State, AuditMessage = auditMessage });
        }
    }

    return audit;

    string CreateAddedMessage(EntityEntry entry)
        => entry.Properties.Aggregate(
            $"Inserting {entry.Metadata.DisplayName()} with ",
            (auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");

    string CreateModifiedMessage(EntityEntry entry)
        => entry.Properties.Where(property => property.IsModified || property.Metadata.IsPrimaryKey()).Aggregate(
            $"Updating {entry.Metadata.DisplayName()} with ",
            (auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");

    string CreateDeletedMessage(EntityEntry entry)
        => entry.Properties.Where(property => property.Metadata.IsPrimaryKey()).Aggregate(
            $"Deleting {entry.Metadata.DisplayName()} with ",
            (auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");
}

O resultado é uma entidade SaveChangesAudit com uma coleção de entidades EntityAudit, uma para cada inserção, atualização ou exclusão. Em seguida, o interceptor insere essas entidades no banco de dados de auditoria.

Dica

ToString é substituído em cada classe de dados de evento do EF Core a fim de gerar a mensagem de log equivalente para o evento. Por exemplo, chamar ContextInitializedEventData.ToString gera "O Entity Framework Core 5.0.0 iniciou 'BlogsContext' usando o provedor 'Microsoft.EntityFrameworkCore.Sqlite' com opções: Nenhum".

Detectar o sucesso

A entidade de auditoria é armazenada no interceptor para ser acessada novamente quando o SaveChanges for bem-sucedido ou falhar. No caso de sucesso, ISaveChangesInterceptor.SavedChanges ou ISaveChangesInterceptor.SavedChangesAsync é chamado.

public int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = true;
    _audit.EndTime = DateTime.UtcNow;

    auditContext.SaveChanges();

    return result;
}

public async ValueTask<int> SavedChangesAsync(
    SaveChangesCompletedEventData eventData,
    int result,
    CancellationToken cancellationToken = default)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = true;
    _audit.EndTime = DateTime.UtcNow;

    await auditContext.SaveChangesAsync(cancellationToken);

    return result;
}

A entidade de auditoria é anexada ao contexto de auditoria, pois já existe no banco de dados e precisa ser atualizada. Em seguida, defina Succeeded e EndTime, que marca essas propriedades como modificadas a fim de que SaveChanges envie uma atualização para o banco de dados de auditoria.

Detectar a falha

A falha é tratada da mesma forma que o sucesso, mas com o método ISaveChangesInterceptor.SaveChangesFailed ou ISaveChangesInterceptor.SaveChangesFailedAsync. Os dados do evento contêm a exceção gerada.

public void SaveChangesFailed(DbContextErrorEventData eventData)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = false;
    _audit.EndTime = DateTime.UtcNow;
    _audit.ErrorMessage = eventData.Exception.Message;

    auditContext.SaveChanges();
}

public async Task SaveChangesFailedAsync(
    DbContextErrorEventData eventData,
    CancellationToken cancellationToken = default)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = false;
    _audit.EndTime = DateTime.UtcNow;
    _audit.ErrorMessage = eventData.Exception.InnerException?.Message;

    await auditContext.SaveChangesAsync(cancellationToken);
}

Demonstração

O exemplo de auditoria contém um aplicativo de console simples que faz alterações no banco de dados de blog e mostra a auditoria que foi criada.

// Insert, update, and delete some entities

using (var context = new BlogsContext())
{
    context.Add(
        new Blog { Name = "EF Blog", Posts = { new Post { Title = "EF Core 3.1!" }, new Post { Title = "EF Core 5.0!" } } });

    await context.SaveChangesAsync();
}

using (var context = new BlogsContext())
{
    var blog = context.Blogs.Include(e => e.Posts).Single();

    blog.Name = "EF Core Blog";
    context.Remove(blog.Posts.First());
    blog.Posts.Add(new Post { Title = "EF Core 6.0!" });

    context.SaveChanges();
}

// Do an insert that will fail

using (var context = new BlogsContext())
{
    try
    {
        context.Add(new Post { Id = 3, Title = "EF Core 3.1!" });

        await context.SaveChangesAsync();
    }
    catch (DbUpdateException)
    {
    }
}

// Look at the audit trail

using (var context = new AuditContext("DataSource=audit.db"))
{
    foreach (var audit in context.SaveChangesAudits.Include(e => e.Entities).ToList())
    {
        Console.WriteLine(
            $"Audit {audit.AuditId} from {audit.StartTime} to {audit.EndTime} was{(audit.Succeeded ? "" : " not")} successful.");

        foreach (var entity in audit.Entities)
        {
            Console.WriteLine($"  {entity.AuditMessage}");
        }

        if (!audit.Succeeded)
        {
            Console.WriteLine($"  Error: {audit.ErrorMessage}");
        }
    }
}

O resultado mostra o conteúdo do banco de dados de auditoria:

Audit 52e94327-1767-4046-a3ca-4c6b1eecbca6 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was successful.
  Inserting Blog with Id: '-2147482647' Name: 'EF Blog'
  Inserting Post with Id: '-2147482647' BlogId: '-2147482647' Title: 'EF Core 3.1!'
  Inserting Post with Id: '-2147482646' BlogId: '-2147482647' Title: 'EF Core 5.0!'
Audit 8450f57a-5030-4211-a534-eb66b8da7040 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was successful.
  Inserting Post with Id: '-2147482645' BlogId: '1' Title: 'EF Core 6.0!'
  Updating Blog with Id: '1' Name: 'EF Core Blog'
  Deleting Post with Id: '1'
Audit 201fef4d-66a7-43ad-b9b6-b57e9d3f37b3 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was not successful.
  Inserting Post with Id: '3' BlogId: '' Title: 'EF Core 3.1!'
  Error: SQLite Error 19: 'UNIQUE constraint failed: Post.Id'.