Interceptadores

Entity Framework Core (EF Core) permitem interceptação, modificação e/ou supressão de EF Core operações. 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 interceptores são diferentes do registro em log e do diagnóstico, já que permitem a modificação ou a supressão da operação que está sendo interceptada. O registro em log simples ou Microsoft.Extensions.Logging são melhores opções para registro em log.

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

Registrando interceptores

Interceptores são registrados usando AddInterceptors ao configurar uma instância DbContext. Isso normalmente é feito em uma substituição de DbContext.OnConfiguring . Por exemplo:

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

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

Dica

OnConfiguring ainda é chamado quando AddDbContext é usado ou uma instância DbContextOptions é passada para o construtor DbContext. Isso 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 interceptador pode ser usada para todas as instâncias 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 interceptador deve implementar uma ou mais interfaces derivadas de IInterceptor . Cada instância deve ser registrada apenas uma vez, mesmo que implemente várias interfaces de interceptação; EF Core roteia eventos para cada interface conforme apropriado.

Interceptação de banco de dados

Observação

A interceptação de banco de dados foi introduzida EF Core 3.0 e só está disponível para provedores de banco de dados relacional. O suporte ao Savepoint foi introduzido EF Core 5.0.

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

Interceptor Operações de banco de dados interceptadas
IDbCommandInterceptor Criando comandos
Executando comandos
Falhas de comando
Descartar o DbDataReader do comando
IDbConnectionInterceptor Abrindo e fechando conexões
Falhas de conexão
IDbTransactionInterceptor Criando transações
Usando transações existentes
Como fazer transações
Reverter transações
Criando e usando pontos de salvar
Falhas de transação

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

Os métodos em cada tipo de interceptador vêm 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 da operação. Por exemplo, é chamado antes de uma consulta ser executada e é chamado depois que a consulta DbCommandInterceptor.ReaderExecuting é enviada ao banco de DbCommandInterceptor.ReaderExecuted dados.

Cada par de métodos tem variações de sincronização e assíncrona. Isso permite que a E/S assíncrona, como solicitar 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

Um IDbCommandInterceptor pode ser usado para modificar SQL antes de ser enviado 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 é 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 marcas EF Core consulta 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 interceptador, pois ela sempre será incluída como um comentário na primeira linha do texto do comando. Ao detectar a marca, o SQL 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 interceptador herda de DbCommandInterceptor para evitar a precisar implementar todos os métodos na interface do interceptador.
  • O interceptador implementa os métodos de sincronização e assíncrono. Isso garante que a mesma dica de consulta seja aplicada para sincronizar e as consultas assíncronas.
  • O interceptador implementa os métodos que são chamados pelo EF Core com o SQL gerado antes de Executing ser enviado ao banco de dados. Contraste 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 ao banco de dados sem modificações:

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

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

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

Dica

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 interceptador lançará se uma chamada de sincronização for feita para abrir a conexão. Isso porque não há nenhum método não assíncrono para obter o token de acesso e não há nenhuma maneira universal e simples de chamar um método assíncrono de contexto não assíncrono sem risco de deadlock.

Aviso

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: Interceptação de comando avançada para cache

EF Core interceptores podem:

  • Diga EF Core suprimir a execução da operação que está sendo interceptada
  • Altere o resultado da operação relatada de volta para EF Core

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

Aviso

Tome cuidado ao alterar o EF Core padrão dessa maneira. EF Core pode se comportar de maneiras inesperadas se ele obtém um resultado anormal que não pode ser processado corretamente. Além disso, este exemplo demonstra os conceitos do interceptador; ele não se destina como 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;

Essa consulta é marcada para que possa ser facilmente detectada no interceptador. A ideia é consultar apenas o banco de dados para uma nova mensagem uma vez por dia. Em outros momentos, o aplicativo usará um resultado armazenado em cache. (O exemplo usa o atraso de 10 segundos no exemplo para simular um novo dia.)

Estado do interceptador

Esse interceptador tem estado: armazena a ID e o texto da mensagem da mensagem diária mais recente consultada, além da hora em que a consulta foi executada. Devido a esse estado, também precisamos de um bloqueio, pois o cache requer que o mesmo interceptador precise ser 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

No método (ou seja, antes de fazer uma chamada de banco de dados), o interceptador detecta a consulta marcada e, em seguida, verifica se há Executing 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 que contém os dados DbDataReader armazenados em cache. Esse InterceptionResult é retornado, causando a supressão da execução da consulta. O leitor de substituição é usado por EF Core como os resultados da consulta.

Esse interceptador 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 válido SQL uma vez que 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. EF Core executará a consulta normalmente. Em seguida, ele retornará ao método do Executed interceptador após a execução. Neste ponto, se o resultado ainda não for um leitor armazenado em cache, a nova ID da mensagem e a cadeia de caracteres são extraídas do leitor real e armazenadas em cache prontas 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 interceptador 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 noe 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 final expire, momento em que o banco de dados é consultado novamente para qualquer nova mensagem.

Interceptação de SaveChanges

Observação

A interceptação de SaveChanges foi introduzida EF Core 5.0.

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

Dica

Os interceptores são poderosos. No entanto, em muitos casos, pode ser mais fácil substituir o método SaveChanges ou usar os eventos do .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

Essa não se destina a ser 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 de 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 interceptador está registrada para cada instância dbContext. Isso porque o interceptador de auditoria contém o estado vinculado à instância de contexto atual.

O contexto de auditoria

O exemplo também contém um segundo DbContext e um modelo usado 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 interceptador

A ideia geral para auditoria com o interceptador é:

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

O primeiro estágio é tratado antes que as alterações sejam enviadas 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;
}

A substituição de métodos assíncronos e assíncronos garante que a auditoria ocorra independentemente de SaveChanges ou SaveChangesAsync ser chamado. Observe também que a sobrecarga assíncrona é capaz de executar E/S assíncrona sem bloqueio no banco de dados de auditoria. Talvez você queira lançar do método de sincronização para garantir que todas as E/S do banco de SavingChanges dados são assíncronas. Em seguida, isso requer que o aplicativo sempre chama SaveChangesAsync e nunca SaveChanges .

A mensagem de auditoria

Cada método interceptador tem um eventData parâmetro que fornece informações contextuais sobre o evento que está sendo interceptado. 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. O interceptador insere essas entidades no banco de dados de auditoria.

Dica

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

Detectando êxito

A entidade de auditoria é armazenada no interceptador para que ela possa ser acessada novamente quando SaveChanges for bem-sucedido ou falhar. Para sucesso ou ISaveChangesInterceptor.SavedChanges 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 está anexada ao contexto de auditoria, pois ela já existe no banco de dados e precisa ser atualizada. Em seguida, definimos e , que marca essas propriedades como Succeeded EndTime modificadas para que SaveChanges envie uma atualização para o banco de dados de auditoria.

Detectando falha

A falha é tratada da mesma maneira que o sucesso, mas no ISaveChangesInterceptor.SaveChangesFailed método ISaveChangesInterceptor.SaveChangesFailedAsync ou . Os dados do evento contêm a exceção que foi lançada.

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, 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 = 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'.