Advanced Audit Logging in .NET Core Using Middleware

Supporting Classes

public class AuditLog
{
    public Guid Id { get; set; }
    public bool IsDeleted { get; set; }
    public DateTime CreatedDate { get; set; }
    public string? CreatorId { get; set; }

    public string? CorrelationId { get; set; }
    public AuditLogEventTypes AuditLogEventType { get; set; }
    public string? EntityId { get; set; }
    public string? EntityName { get; set; }
    public string? UserName { get; set; }
    public string? UserRole { get; set; }
    public string? SessionId { get; set; }
    public string? ClientIpAddress { get; set; }
    public string? BrowserInfo { get; set; }
    public string? ApplicationName { get; set; }
    public string? MachineName { get; set; }
    public string? MachineVersion { get; set; }
    public string? MachineOsVersion { get; set; }
    public string? ServiceName { get; set; }
    public string? MethodName { get; set; }
    public string? HttpMethod { get; set; }
    public string? RequestUrl { get; set; }
    public string? ResponseStatus { get; set; }
    public DateTime? ExecutionTime { get; set; }
    public string? ExecutionDuration { get; set; }
    public string? RequestHeaders { get; set; }
    public string? QueryParameters { get; set; }
    public string? BodyParameters { get; set; }
    public string? Exception { get; set; }

    public ICollection<EntityPropertyChange> EntityPropertyChanges { get; set; } = new List<EntityPropertyChange>();
}

public class EntityPropertyChange
{
    public Guid Id { get; set; }
    public string? PropertyName { get; set; }
    public string? OriginalValue { get; set; }
    public string? NewValue { get; set; }
    public string? PropertyTypeFullName { get; set; }
    public Guid AuditLogId { get; set; }
}

public class AuditScope
{
    public List<AuditLog> Logs { get; set; } = new List<AuditLog>();
}


Audit Logging with Entity Tracking in DbContext

public class AppDbContext : DbContext
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly AuditScope _auditScope;

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {
    }

    public AppDbContext(DbContextOptions<AppDbContext> options, IHttpContextAccessor httpContextAccessor, AuditScope auditScope)
        : base(options)
    {
        _httpContextAccessor = httpContextAccessor;
        _auditScope = auditScope;
    }

    public override int SaveChanges()
    {
        var auditEntries = OnBeforeSaveChanges();
        var result = base.SaveChanges();
        OnAfterSaveChanges(auditEntries);
        return result;
    }

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        var auditEntries = OnBeforeSaveChanges();
        var result = await base.SaveChangesAsync(cancellationToken);
        OnAfterSaveChanges(auditEntries);
        return result;
    }

    private List<EntityEntry> OnBeforeSaveChanges()
    {
        return ChangeTracker.Entries().ToList();
    }

    private void OnAfterSaveChanges(List<EntityEntry> auditEntries)
    {
        foreach (var entry in auditEntries)
        {
            if (entry.Entity is AuditLog) continue;
            
            var tableName = entry.Metadata.GetTableName();

            var auditLog = new AuditLog();
            auditLog.Id = Guid.NewGuid();
            auditLog.AuditLogEventType = entry.State switch
            {
                EntityState.Added => AuditLogEventTypes.Added,
                EntityState.Modified => AuditLogEventTypes.Modified,
                EntityState.Deleted => AuditLogEventTypes.Deleted,
                EntityState.Unchanged => AuditLogEventTypes.Unchanged,
                EntityState.Detached => AuditLogEventTypes.Detached,
                _ => AuditLogEventTypes.Unknown
            };
            auditLog.EntityId = entry.OriginalValues[entry.Metadata.FindPrimaryKey()!.Properties.First().Name]!.ToString();
            auditLog.EntityName = tableName;
            auditLog.UserName = _httpContextAccessor.HttpContext?.User?.GetUserName();
            auditLog.UserRole = _httpContextAccessor.HttpContext?.User?.GetUserRole() != null 
                ? string.Join(",", _httpContextAccessor.HttpContext.User.GetUserRole()!) 
                : null;

            foreach (var property in entry.Properties)
            {
                var columnName = property.Metadata.Name;
                var oldValue = property.OriginalValue?.ToString();
                var newValue = property.CurrentValue?.ToString();

                if (!Equals(oldValue, newValue))
                {
                    var entityPropertyChange = new EntityPropertyChange
                    {
                        Id = Guid.NewGuid(),
                        NewValue = newValue,
                        OriginalValue = oldValue,
                        PropertyName = columnName,
                        PropertyTypeFullName = property.Metadata.ClrType.FullName,
                        AuditLogId = auditLog.Id
                    };

                    auditLog.EntityPropertyChanges.Add(entityPropertyChange);
                }
            }
            
            _auditScope.Logs.Add(auditLog);
        }
    }
}


The AuditLogMiddleware Class

This middleware captures a wide range of information, including HTTP request/response details, execution times, and exception messages.

public class AuditLogMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IConfiguration _configuration;

    public AuditLogMiddleware(RequestDelegate next, IConfiguration configuration)
    {
        _next = next;
        _configuration = configuration;
    }

    public async Task InvokeAsync(HttpContext context, AuditScope auditScope)
    {
        var stopwatch = Stopwatch.StartNew();
        var executionTime = DateTime.UtcNow;
        var applicationName = _configuration.GetApplicationName();
        var correlationId = context.TraceIdentifier;
        
        var originalBodyStream = context.Response.Body;
        using var responseBody = new MemoryStream();
        context.Response.Body = responseBody;
        
        await _next(context);
        
        stopwatch.Stop();

        if (auditScope.Logs.Any())
        {
            foreach (var auditLog in auditScope.Logs)
            {
                var forwardedHeader = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
                var ipAddress = string.IsNullOrEmpty(forwardedHeader)
                    ? context.Connection.RemoteIpAddress?.ToString()
                    : forwardedHeader.Split(',')[0];
                
                auditLog.CorrelationId = correlationId;
                auditLog.SessionId = context.User.GetUserCustomProperty("SessionId");
                auditLog.ClientIpAddress = ipAddress;
                auditLog.BrowserInfo = context.Request.Headers["User-Agent"];
                auditLog.ApplicationName = applicationName;
                auditLog.MachineName = Environment.MachineName;
                auditLog.MachineVersion = Environment.Version.ToString();
                auditLog.MachineOsVersion = Environment.OSVersion.ToString();
                var routeData = context.GetRouteData();
                auditLog.ServiceName = routeData?.Values["controller"]?.ToString();
                auditLog.MethodName = routeData?.Values["action"]?.ToString();
                auditLog.HttpMethod = context.Request.Method;
                auditLog.RequestUrl = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}{context.Request.QueryString}";
                auditLog.ResponseStatus = context.Response.StatusCode.ToString();
                auditLog.ExecutionTime = executionTime;
                auditLog.ExecutionDuration = stopwatch.ElapsedMilliseconds.ToString();
                auditLog.RequestHeaders = JsonConvert.SerializeObject(
                    context.Request.Headers.ToDictionary(
                        header => header.Key, header => header.Value.ToString()
                        )
                    );
                auditLog.QueryParameters = JsonConvert.SerializeObject(
                    context.Request.Query.ToDictionary(
                        query => query.Key, query => query.Value.ToString()
                        )
                    );
                
                if (context.Request.Body.CanSeek == true)
                {
                    context.Request.Body.Position = 0;
                }
                else
                {
                    context.Request.EnableBuffering();
                }
                
                string originalContent;
                using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true))
                {
                    originalContent = await reader.ReadToEndAsync();
                    context.Request.Body.Position = 0;
                }
                
                var bodyParameters = JsonConvert.DeserializeObject<Dictionary<string, object>>(originalContent);
                if (bodyParameters != null && bodyParameters.Any())
                {
                    var bodyContent = JsonConvert.SerializeObject(bodyParameters);
                    auditLog.BodyParameters = bodyContent;
                }
                
                if (context.Response.StatusCode >= 400)
                {
                    responseBody.Seek(0, SeekOrigin.Begin);
                    var responseText = await new StreamReader(responseBody).ReadToEndAsync();
                    
                    auditLog.Exception = responseText;
                    
                    responseBody.Seek(0, SeekOrigin.Begin);
                    await responseBody.CopyToAsync(originalBodyStream);
                }
            }
        }
        
    }
}


Program.cs

app.UseMiddleware<AuditLogMiddleware>();
   app.UseRouting();
   app.UseAuthorization();
builder.Services.AddScoped<AuditScope>();