EfCore.AuditLog 1.0.0

dotnet add package EfCore.AuditLog --version 1.0.0
                    
NuGet\Install-Package EfCore.AuditLog -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="EfCore.AuditLog" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="EfCore.AuditLog" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="EfCore.AuditLog" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add EfCore.AuditLog --version 1.0.0
                    
#r "nuget: EfCore.AuditLog, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package EfCore.AuditLog@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=EfCore.AuditLog&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=EfCore.AuditLog&version=1.0.0
                    
Install as a Cake Tool

EfCore.AuditLog – Channel / Queue Sink Support

NuGet Version .NET License: MIT

This library provides an EF Core SaveChanges interceptor that captures audit entries and writes them to a separate audit database. Two write modes are supported:

  • Direct (default): each SaveAsync call writes entries directly to the audit DB via IDbContextFactory<AuditDbContext>.
  • Channel (recommended for throughput): entries are enqueued into a bounded Channel and a background worker batches and persists them.

Important: the library is provider-agnostic and does not depend on any specific database provider (Postgres, SQL Server, etc.). Consumers register the audit database provider when calling AddEfCoreAudit by passing an Action<DbContextOptionsBuilder> – this keeps the library independent of EF Core providers.

Why this is safe

  • EfCore.AuditLog.csproj contains only EF Core and Microsoft.Extensions packages (no Npgsql or SqlServer provider packages). That means the library itself does not force any DB provider.
  • AddEfCoreAudit requires the caller to supply an Action<DbContextOptionsBuilder> (configureAuditDb). The consumer chooses the provider inside that action (UseNpgsql, UseSqlServer, UseSqlite, etc.). This keeps the library independent of any specific provider.

How consumers pick a provider (examples)

  • PostgreSQL:
services.AddEfCoreAudit(o => o.UseNpgsql(Configuration.GetConnectionString("AuditDb")), auditOptions);
  • SQL Server:
services.AddEfCoreAudit(o => o.UseSqlServer(Configuration.GetConnectionString("AuditDb")), auditOptions);
  • SQLite:
services.AddEfCoreAudit(o => o.UseSqlite(Configuration.GetConnectionString("AuditDb")), auditOptions);

You can also use the IConfiguration overload added in this library to bind AuditOptions from configuration and still supply the provider in the configure action:

// binds AuditOptions from Configuration.GetSection("Audit") and forwards to the main registration
services.AddEfCoreAudit(Configuration, o => {
    // pick provider here
    // o.UseNpgsql(...);
    // o.UseSqlServer(...);
});

Configuration

Add the library to your project and register it in DI using one of the AddEfCoreAudit overloads.

  1. Direct mode (default)

In Program.cs / Startup.cs (consumer chooses provider):

// PostgreSQL example
services.AddEfCoreAudit(o => {
    o.UseNpgsql(Configuration.GetConnectionString("AuditDb"));
});

// SQL Server example
services.AddEfCoreAudit(o => {
    o.UseSqlServer(Configuration.GetConnectionString("AuditDb"));
});

// Provider-agnostic placeholder
services.AddEfCoreAudit(o => {
    // o.UseYourProvider(Configuration.GetConnectionString("AuditDb"));
});
  1. Channel mode

Create an AuditOptions instance or configure via overload:

var auditOptions = new AuditOptions {
    Mode = AuditOptions.AuditModeType.Channel,
    BatchSize = 200,
    FlushInterval = TimeSpan.FromSeconds(2),
    MaxQueueLength = 20000,
    DropWhenFull = false
};

// Bind provider as above
services.AddEfCoreAudit(o => {
    // o.UseNpgsql(Configuration.GetConnectionString("AuditDb"));
    // o.UseSqlServer(...);
}, auditOptions);

How it works

  • In Channel mode the library registers a Channel<AuditEntry> and a ChannelAuditSink which enqueues audit entries.
  • A hosted AuditChannelWorker reads the channel, batches entries according to BatchSize and FlushInterval, and writes them with IDbContextFactory<AuditDbContext>.
  • If DropWhenFull is false the channel is configured with FullMode=Wait (producers block until space). If true, FullMode=DropOldest and overflowed entries are dropped.

Integrating into XAF applications (Blazor / Web API)

Below are concrete samples showing how to wire the audit infrastructure into XAF Blazor (Server) and XAF Web API projects. The important points:

  • call services.AddEfCoreAudit(...) in ConfigureServices and pass a provider-specific configuration action
  • attach AuditSaveChangesInterceptor to your application DbContext registration via options.AddInterceptors(...)
  • optionally register an ambient IOperationContext to provide Actor and CorrelationId

Installation

Install the NuGet package:

# .NET CLI
dotnet add package EfCore.AuditLog

# Package Manager Console
Install-Package EfCore.AuditLog

# PackageReference (add to .csproj)
<PackageReference Include="EfCore.AuditLog" Version="1.0.0" />

You'll also need to install your preferred database provider:

# For PostgreSQL
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL

# For SQL Server  
dotnet add package Microsoft.EntityFrameworkCore.SqlServer

# For SQLite
dotnet add package Microsoft.EntityFrameworkCore.Sqlite

Configuration

appsettings.json example

{
  "ConnectionStrings": {
    "ConnectionString": "Host=localhost;Database=appdb;Username=app;Password=pass",
    "ConnectionString_audit": "Host=localhost;Database=auditdb;Username=audit;Password=pass"
  },
  "Audit": {
    "Mode": "Channel",
    "BatchSize": 200,
    "FlushInterval": "00:00:02",
    "MaxQueueLength": 20000,
    "DropWhenFull": false
  }
}

You can read the Audit section and map it to AuditOptions when registering AddEfCoreAudit (examples below).


XAF – Blazor (Server) integration (Startup.cs)

This matches the typical XAF Blazor Server Startup pattern used in this solution.

public void ConfigureServices(IServiceCollection services)
{
    // optional: ambient operation context (no HttpContext required)
    services.AddSingleton<IOperationContext, OperationContext>();

    // load options from configuration (example)
    var auditOptions = Configuration.GetSection("Audit").Get<AuditOptions>();

    // register audit DB and sink (consumer chooses provider here)
    services.AddEfCoreAudit(o => {
        var auditConn = Configuration.GetConnectionString("ConnectionString_audit") ?? Configuration.GetConnectionString("ConnectionString");
        // Example provider registrations (pick one):
        // o.UseNpgsql(auditConn);
        // o.UseSqlServer(auditConn);
        // o.UseSqlite(auditConn);
    }, auditOptions);

    services.AddXaf(Configuration, builder => {
        builder.Modules
            // ... add modules
        builder.ObjectSpaceProviders
            .AddSecuredEFCore(options => { options.PreFetchReferenceProperties(); })
            .WithDbContext<MyAppDbContext>((serviceProvider, options) => {
                options.UseConnectionString(Configuration.GetConnectionString("ConnectionString"));

                // attach interceptor (registered by AddEfCoreAudit)
                var interceptor = serviceProvider.GetService<AuditSaveChangesInterceptor>();
                if (interceptor != null)
                {
                    options.AddInterceptors(interceptor);
                }
            })
            .AddNonPersistent();
    });
}

Notes for Blazor Server

  • The interceptor buffers audit entries during SavingChanges and flushes after SaveChanges completes to avoid blocking the synchronization context.
  • Channel mode further decouples request latency from audit DB writes.

XAF – Web API integration (Startup.cs)

Use the same registration approach in your Web API Startup.

public void ConfigureServices(IServiceCollection services)
{
    // optional: ambient operation context
    services.AddSingleton<IOperationContext, OperationContext>();

    var auditOptions = Configuration.GetSection("Audit").Get<AuditOptions>();

    services.AddEfCoreAudit(o => {
        var auditConn = Configuration.GetConnectionString("ConnectionString_audit") ?? Configuration.GetConnectionString("ConnectionString");
        // Example provider registrations (pick one):
        // o.UseNpgsql(auditConn);
        // o.UseSqlServer(auditConn);
    }, auditOptions);

    services.AddXafWebApi(builder => {
        builder.ObjectSpaceProviders
            .AddSecuredEFCore(options => { options.PreFetchReferenceProperties(); })
            .WithDbContext<MyAppDbContext>((serviceProvider, options) => {
                options.UseConnectionString(Configuration.GetConnectionString("ConnectionString"));

                // attach interceptor
                var interceptor = serviceProvider.GetService<AuditSaveChangesInterceptor>();
                if (interceptor != null)
                {
                    options.AddInterceptors(interceptor);
                }
            })
            .AddNonPersistent();

        // ... rest of Web API configuration
    }, Configuration);
}

Notes for Web API

  • Web API apps are already running on an async environment; when using Channel mode the background worker persists audits without blocking request threads.
  • If you do not register IOperationContext, the interceptor will still emit audit entries but Actor/CorrelationId will be null.

OperationContext (optional)

To supply Actor and CorrelationId without HttpContext you can register a small ambient context (AsyncLocal-based):

public interface IOperationContext
{
    string? Actor { get; }
    string? CorrelationId { get; }
    IDisposable Use(string? actor = null, string? correlationId = null);
}

public sealed class OperationContext : IOperationContext
{
    private sealed class State { public string? Actor; public string? CorrelationId; }
    private static readonly AsyncLocal<State?> _state = new();

    public string? Actor => _state.Value?.Actor;
    public string? CorrelationId => _state.Value?.CorrelationId;

    public IDisposable Use(string? actor = null, string? correlationId = null)
    {
        var prev = _state.Value;
        var next = new State { Actor = actor ?? prev?.Actor, CorrelationId = correlationId ?? prev?.CorrelationId };
        _state.Value = next;
        return new Revert(() => _state.Value = prev);
    }

    private sealed class Revert : IDisposable { private readonly Action _undo; private bool _done; public Revert(Action undo) => _undo = undo; public void Dispose() { if (_done) return; _undo(); _done = true; } }
}

Usage in request handler / controller / service:

using (opCtx.Use(actor: "user@example.com", correlationId: Guid.NewGuid().ToString("n")))
{
    // Save changes on your XAF object space / DbContext – audit entries will include actor & correlation id
}

Recommendations and troubleshooting

  • For development you can use EnsureCreated on the AuditDbContext factory to create the audit database on startup.
  • If you see the application freeze in Blazor Server: ensure you do not call async APIs synchronously – the interceptor implementation in this library avoids sync-over-async and uses post-save buffering or Channel mode.
  • Tune BatchSize and FlushInterval to balance latency vs throughput.
  • Monitor logs for the background worker to detect persistent failures when writing audits.


Web API Controller Usage

The library includes an IAuditService that can be injected into your controllers to query audit entries. Here's a complete example:

Startup Configuration (XAF Web API)

public void ConfigureServices(IServiceCollection services)
{
    services.AddScoped<IAuthenticationTokenProvider, JwtTokenProviderService>();

    // Make IHttpContextAccessor available for interceptor to capture actor
    services.AddHttpContextAccessor();

    // Register audit infrastructure (audit DB and interceptor)
    var mainConn = Configuration.GetConnectionString("ConnectionString");
    var auditConn = Configuration.GetConnectionString("ConnectionString_audit") ?? mainConn;

    var auditOptions = Configuration.GetSection("Audit").Get<AuditOptions>() ?? new AuditOptions();
    if (string.IsNullOrEmpty(auditOptions.SourceApp))
    {
        auditOptions.SourceApp = HostEnvironment.ApplicationName;
    }

    services.AddEfCoreAudit(options =>
    {
        if (!string.IsNullOrEmpty(auditConn))
        {
            options.UseNpgsql(auditConn);
        }
    }, auditOptions);

    services.AddXafWebApi(builder =>
    {
        builder.ConfigureOptions(options =>
        {
            options.BusinessObject<Customer>();
        });

        builder.ObjectSpaceProviders
            .AddSecuredEFCore(options => { options.PreFetchReferenceProperties(); })
            .WithDbContext<MyAppDbContext>((serviceProvider, options) =>
            {
                options.UseConnectionString(Configuration.GetConnectionString("ConnectionString"));

                // Attach the audit interceptor to capture changes
                try
                {
                    var interceptor = serviceProvider.GetRequiredService<AuditSaveChangesInterceptor>();
                    options.AddInterceptors(interceptor);
                }
                catch
                {
                    // ignore if interceptor not registered
                }
            })
            .AddNonPersistent();

        builder.Security
            .UseIntegratedMode(options =>
            {
                options.RoleType = typeof(PermissionPolicyRole);
                options.UserType = typeof(ApplicationUser);
            })
            .AddPasswordAuthentication();
    }, Configuration);

    services.AddControllers();
    services.AddAuthentication().AddJwtBearer(/* JWT config */);
}

Controller Implementation

[Route("api/[controller]")]
[ApiController]
[Authorize]
public class AuditController : ControllerBase
{
    private readonly IAuditService _auditService;

    public AuditController(IAuditService auditService)
    {
        _auditService = auditService;
    }

    // GET api/audit - Query audit entries with filtering
    [HttpGet]
    public async Task<IActionResult> Query(
        [FromQuery] string? sourceApp,
        [FromQuery] string? tableName,
        [FromQuery] string? operation,
        [FromQuery] string? actor,
        [FromQuery] string? correlationId,
        [FromQuery] DateTime? fromUtc,
        [FromQuery] DateTime? toUtc,
        [FromQuery] string? fullText,
        [FromQuery] int skip = 0,
        [FromQuery] int take = 50,
        [FromQuery] string? orderBy = "Timestamp",
        [FromQuery] bool desc = true,
        CancellationToken ct = default)
    {
        var queryOptions = new AuditQueryOptions
        {
            SourceApp = sourceApp,
            TableName = tableName,
            Actor = actor,
            CorrelationId = correlationId,
            FromUtc = fromUtc,
            ToUtc = toUtc,
            FullText = fullText
        };

        if (!string.IsNullOrEmpty(operation))
        {
            if (Enum.TryParse<AuditOperation>(operation, true, out var op))
                queryOptions.Operation = op;
        }

        var paging = new PagingOptions { Skip = skip, Take = take, OrderBy = orderBy, Descending = desc };
        var result = await _auditService.QueryAsync(queryOptions, paging, ct);
        return Ok(result);
    }

    // GET api/audit/{id} - Get specific audit entry by ID
    [HttpGet("{id:int}")]
    public async Task<IActionResult> GetById(int id, CancellationToken ct = default)
    {
        var entry = await _auditService.GetByIdAsync(id, ct);
        if (entry == null) return NotFound();
        return Ok(entry);
    }

    // POST api/audit/batch - Save multiple audit entries (admin endpoint)
    [HttpPost("batch")]
    [Authorize(Roles = "Admin")] // Protect this endpoint appropriately
    public async Task<IActionResult> SaveBatch([FromBody] IEnumerable<AuditEntryDto> models, CancellationToken ct = default)
    {
        await _auditService.SaveManyAsync(models, ct);
        return Accepted();
    }
}

How Actor is Captured in Web API

The library automatically captures the actor (authenticated user) from the Web API request context:

  1. HttpContextAccessor Registration: The library requires IHttpContextAccessor to be registered:

    services.AddHttpContextAccessor();
    
  2. Actor Resolution: The AuditSaveChangesInterceptor automatically extracts the actor from the current HTTP request using this priority order:

    • User.Identity.Name (primary identity name)
    • ClaimTypes.NameIdentifier claim
    • "sub" claim (JWT subject)
    • ClaimTypes.Email claim
  3. JWT Integration: For JWT-authenticated APIs, the actor is typically extracted from:

    {
      "sub": "user@example.com",
      "name": "John Doe",
      "email": "user@example.com"
    }
    
  4. Example API Request:

    POST /api/customers
    Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
    Content-Type: application/json
    
    {
      "name": "New Customer",
      "email": "customer@example.com"
    }
    

    The audit entry will automatically capture:

    • Actor: "user@example.com" (from JWT claims)
    • Operation: "Insert"
    • TableName: "Customers"
    • Timestamp: Current UTC time
    • Changes: JSON diff of the new entity

Example API Queries

Query recent changes by a specific user:

GET /api/audit?actor=user@example.com&take=20&desc=true

Filter by table and operation:

GET /api/audit?tableName=Customers&operation=Update&fromUtc=2023-01-01T00:00:00Z

Search audit entries:

GET /api/audit?fullText=important+customer&take=10

Connection String Configuration

{
  "ConnectionStrings": {
    "ConnectionString": "Host=localhost;Database=appdb;Username=app;Password=pass",
    "ConnectionString_audit": "Host=localhost;Database=auditdb;Username=audit;Password=pass"
  },
  "Audit": {
    "Mode": "Channel",
    "BatchSize": 200,
    "FlushInterval": "00:00:02",
    "MaxQueueLength": 20000,
    "DropWhenFull": false
  }
}

The audit system will automatically capture all changes made through your XAF Web API endpoints, providing complete traceability of who changed what and when.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 173 9/24/2025

Initial release with EF Core interceptor-based audit logging, channel processing, and XAF integration.