Partilhar via


Intercetores

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

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

Os intercetores são registados por instância no 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.

Registo de intercetores

Os intercetadores são registados utilizando AddInterceptors ao configurar uma instância de DbContext. Isso geralmente é feito ao sobrescrever 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 passar para o construtor DbContext.

Sugestão

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

Os intercetadores geralmente são sem estado, o que significa que uma única instância do intercetador pode ser usada para todas as instâncias de 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 do intercetor deve implementar uma ou mais interfaces derivadas do IInterceptor. Cada instância só deve ser registada uma vez, mesmo que implemente várias interfaces de interceção; O EF Core encaminhará eventos para cada interface conforme apropriado.

Interceção de bases de dados

Observação

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

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

Intercetor Operações de banco de dados intercetadas
IDbCommandInterceptor Criando comandos
Executando comandos
Falhas de comando
Descartando o DbDataReader do comando
IDbConnectionInterceptor Abrindo e fechando conexões
Falhas de conexão
IDbTransactionInterceptor Criação de transações
Usando transações
existentes Confirmando transações
Revertendo transações
Criação e uso de savepoints
Falhas de transação

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

Os métodos em cada tipo de intercetador vêm em pares, com o primeiro sendo chamado antes da operação de banco de dados ser iniciada e o segundo após a conclusão da operação. Por exemplo, DbCommandInterceptor.ReaderExecuting é chamado antes de uma consulta ser executada e DbCommandInterceptor.ReaderExecuted é chamado após a consulta ter sido enviada para o banco de dados.

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

Exemplo: intercetação de comandos para adicionar dicas de consulta

Sugestão

Você pode baixar o exemplo de intercetador de comando do GitHub.

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

Muitas vezes, a parte mais complicada da intercetação é determinar quando o comando corresponde à consulta que precisa ser modificada. Analisar o SQL é uma opção, mas tende a ser frágil. Outra opção é usar tags de consulta EF Core para marcar cada consulta que deve ser modificada. Por exemplo:

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

Essa tag pode ser detetada no intercetor, pois sempre será incluída como um comentário na primeira linha do texto do comando. Ao detetar a tag, 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 intercetor herda de DbCommandInterceptor para evitar ter que implementar todos os métodos na interface de intercetor.
  • O intercetor implementa métodos síncronos e assíncronos. Isso garante que a mesma dica de consulta seja aplicada a consultas sincronizadas e assíncronas.
  • O intercetor implementa os Executing métodos que são chamados pelo EF Core com o SQL gerado antes de ser enviado para o banco de dados. Compare isso com os Executed métodos, que são chamados depois que a chamada de banco de dados é retornada.

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 para o banco de dados sem modificações:

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

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

Sugestão

Você pode baixar o exemplo de intercetador de conexão do GitHub.

Um IDbConnectionInterceptor pode ser usado para manipular o DbConnection antes de ser usado para se conectar ao banco de dados. Isso pode ser usado para obter um token de acesso do Azure Ative Directory (AAD). 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;
    }
}

Sugestão

Microsoft.Data.SqlClient agora suporta autenticação AAD via cadeia de conexão. Consulte SqlAuthenticationMethod para obter mais informações.

Advertência

Observe que o intercetador é acionado se uma chamada de sincronização for feita para abrir a conexão. Isso ocorre porque não há nenhum método não assíncrono para obter o token de acesso e não há uma maneira universal e simples de chamar um método assíncrono de contexto não assíncrono sem correr o risco de deadlock.

Advertência

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

Exemplo: Intercetação avançada de comandos para armazenamento em cache

Os intercetores EF Core podem:

  • Instrua o EF Core para suprimir a execução da operação que está a ser intercetada
  • Alterar o resultado da operação reportada de volta para o EF Core

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

Advertência

Tenha cuidado ao alterar o comportamento padrão do EF Core dessa maneira. O EF Core pode se comportar de maneiras inesperadas se obtiver um resultado anormal que não pode ser processado corretamente. Além disso, este exemplo demonstra conceitos de intercetores; ele não se destina a ser um modelo para uma implementação robusta de cache de segundo nível.

Neste exemplo, o aplicativo frequentemente executa 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;

Esta consulta é marcada para que possa ser facilmente detetada no intercetor. A ideia é consultar o banco de dados apenas para uma nova mensagem uma vez por dia. Em outras ocasiões, o aplicativo usará um resultado armazenado em cache. (A amostra usa um atraso de 10 segundos para simular o início de um novo dia.)

Estado do intercetor

Esse intercetador tem monitoração de estado: armazena o ID e o texto da mensagem diária mais recente consultada, além do tempo em que essa consulta foi executada. Devido a esse estado, também precisamos de um bloqueio , já que o cache requer que o mesmo intercetador seja usado por várias instâncias de contexto.

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

Antes da execução

Executing No método (ou seja, antes de fazer uma chamada de banco de dados), o intercetador deteta a consulta marcada e, em seguida, verifica se há um resultado armazenado em cache. Se esse resultado for encontrado, 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 passa uma substituição DbDataReader contendo os dados armazenados em cache. Esse InterceptionResult é retornado, causando o impedimento da execução da consulta. Em vez disso, o EF Core utiliza o leitor de substituição para obter os resultados da consulta.

Este intercetor 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 SQL válido, pois a consulta agora não será executada.

Após a execução

Se nenhuma mensagem em cache estiver disponível, ou se tiver expirado, o código acima não suprime o resultado. Portanto, o EF Core executará a consulta normalmente. Em seguida, retornará ao método Executed do intercetador após a execução. Neste ponto, se o resultado ainda não for um leitor em cache, o novo ID e cadeia de caracteres da mensagem serão extraídos do leitor real e armazenados em cache prontos para o próximo uso dessa 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 de intercetador 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é que o tempo limite expire, momento em que o banco de dados é consultado novamente para qualquer nova mensagem.

Intercetação Guardar Alterações

Sugestão

Você pode baixar o exemplo de intercetador SaveChanges do GitHub.

SaveChanges e SaveChangesAsync pontos de interceção são definidos pela interface ISaveChangesInterceptor. Quanto a outros intercetores, a SaveChangesInterceptor classe base com métodos no-op é fornecida como uma conveniência.

Sugestão

Os intercetores 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 em DbContext.

Exemplo: Interceção de SaveChanges para auditoria

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

Observação

Não se pretende que esta seja uma solução de auditoria robusta. Trata-se, antes, de um exemplo simplista utilizado para demonstrar as características da interceção.

O contexto da aplicação

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

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 intercetor é registada para cada instância de DbContext. Isso ocorre porque o intercetador de auditoria contém estado vinculado à instância de contexto atual.

Contexto da auditoria

O exemplo também inclui um segundo DbContext e um modelo utilizado 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 para auditar com o intercetor é:

  • Uma mensagem de auditoria é criada no início de SaveChanges e gravada no banco de dados de auditoria
  • SaveChanges tem permissão para continuar
  • Se SaveChanges for bem-sucedido, a mensagem de auditoria é atualizada para indicar o êxito.
  • Se SaveChanges falhar, a mensagem de auditoria será atualizada para indicar a falha

A primeira fase é efetuada antes que quaisquer alterações sejam enviadas ao banco de dados, usando sobreposiçõ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 ambos os 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 E/S assíncronas sem bloqueio para o banco de dados de auditoria. Pode querer lançar uma exceção no método de sincronização SavingChanges para garantir que todas as operações de entrada/saída 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 intercetador tem um eventData parâmetro que fornece informações contextuais sobre o evento que está sendo intercetado. Nesse caso, o aplicativo atual DbContext é 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 SaveChangesAudit entidade com uma coleção de EntityAudit entidades, uma para cada inserção, atualização ou exclusão. Em seguida, o intercetador insere essas entidades no banco de dados de auditoria.

Sugestão

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

Detetar o sucesso

A entidade de auditoria é armazenada no intercetor para que possa ser acedida novamente, quer o SaveChanges seja bem-sucedido, quer falhe. Para alcançar o sucesso, ISaveChangesInterceptor.SavedChanges ou ISaveChangesInterceptor.SavedChangesAsync é utilizado.

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 está ligada ao contexto da auditoria, uma vez que já existe na base de dados e necessita de ser atualizada. Em seguida, definimos Succeeded e EndTime, que marca essas propriedades como modificadas para que SaveChanges envie uma atualização para o banco de dados de auditoria.

Deteção de falhas

O fracasso é tratado da mesma forma que o sucesso, mas no método ISaveChangesInterceptor.SaveChangesFailed ou ISaveChangesInterceptor.SaveChangesFailedAsync. Os dados do evento contêm a exceção que foi 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 blogs e, em seguida, 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 = await context.Blogs.Include(e => e.Posts).SingleAsync();

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

    await context.SaveChangesAsync();
}

// 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 await context.SaveChangesAudits.Include(e => e.Entities).ToListAsync())
    {
        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 da base 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'.