Audit logging is a critical component of modern applications, providing transparency, security, and compliance. This guide explores a sophisticated audit logging solution using Entity Framework Core (EF Core) Interceptors, explaining each component in detail, providing real-world examples, and discussing best practices for implementation.
Table of Contents
- Introduction to Audit Logging
- Core Components Overview
- Deep Dive into the Audit Interceptor
- Real-World Example: E-Commerce Product Updates
- Advanced Configuration & Best Practices
- Performance Considerations
- Security Implications
- Future Enhancements
1. Introduction to Audit Logging
Audit logging captures who changed what data and when, serving three primary purposes:
-
Regulatory Compliance
- GDPR, HIPAA, PCI-DSS requirements
- Legal evidence in disputes
-
Operational Integrity
- Debugging data anomalies
- Recovery from accidental changes
-
Business Intelligence
- User behavior analysis
- Change pattern recognition
Traditional approaches often require manual logging in every service method. Our EF Core Interceptor solution automates this process through database-level observation.
2. Core Components Overview
2.1 Audit Log Entity
public class AuditLog { public long Id { get; set; } public string TableName { get; set; } // Modified entity type public long RecordId { get; set; } // Modified record ID public string Operation { get; set; } // CREATE/UPDATE/DELETE public string OldValues { get; set; } // JSON snapshot before changes public string NewValues { get; set; } // JSON snapshot after changes public long ModifiedBy { get; set; } // User ID from context public DateTimeOffset ModifiedAt { get; set; } // UTC timestamp }
2.2 User Context Service
public interface IUserContext { long? CurrentUserId { get; } } // Implementation fetching user from JWT public class JwtUserContext : IUserContext { private readonly IHttpContextAccessor _contextAccessor; public JwtUserContext(IHttpContextAccessor contextAccessor) => _contextAccessor = contextAccessor; public long? CurrentUserId => long.TryParse( _contextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId ) ? userId : null; }
2.3 Audit Interceptor Architecture
sequenceDiagram participant Client participant DbContext participant AuditInterceptor participant Database Client->>DbContext: SaveChangesAsync() DbContext->>AuditInterceptor: SavingChangesAsync() AuditInterceptor->>AuditInterceptor: Analyze ChangeTracker AuditInterceptor->>Database: Save AuditLogs AuditInterceptor->>DbContext: Continue original save DbContext->>Database: Save business entities Database-->>DbContext: Success DbContext-->>Client: Result
3. Deep Dive into the Audit Interceptor
3.1 Change Detection Mechanism
The interceptor hooks into EF Core's SaveChangesAsync
pipeline:
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default) { // Prevent recursive saving if (_isSaving) return await base.SavingChangesAsync(...); try { _isSaving = true; var audits = new List<AuditLog>(); foreach (var entry in eventData.Context.ChangeTracker.Entries()) { if (ShouldAudit(entry)) audits.Add(CreateAuditEntry(entry)); } await SaveAudits(eventData.Context, audits); return await base.SavingChangesAsync(...); } finally { _isSaving = false; } } private bool ShouldAudit(EntityEntry entry) => entry.Entity is not AuditLog && entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted;
3.2 Change Processing Logic
Handles different entity states with precision:
Added Entities
if (entry.State == EntityState.Added) { audit.NewValues = JsonSerializer.Serialize(entry.CurrentValues.ToObject()); }
Deleted Entities
if (entry.State == EntityState.Deleted) { audit.OldValues = JsonSerializer.Serialize(entry.OriginalValues.ToObject()); }
Modified Entities
var changes = entry.Properties .Where(p => p.IsModified && !Equals(p.OriginalValue, p.CurrentValue)) .ToDictionary( p => p.Metadata.Name, p => new { Old = p.OriginalValue, New = p.CurrentValue } ); audit.OldValues = changes.Any() ? JsonSerializer.Serialize(changes.ToDictionary(k => k.Key, k => k.Value.Old)) : null; audit.NewValues = changes.Any() ? JsonSerializer.Serialize(changes.ToDictionary(k => k.Key, k => k.Value.New)) : null;
4. Real-World Example: E-Commerce Product Updates
4.1 Scenario: Price & Stock Adjustment
// Original product state var product = new Product { Id = 1, Name = "Wireless Headphones", Price = 199.99m, Stock = 50 }; // User updates product.Price = 179.99m; product.Stock = 45; // Save changes await _context.SaveChangesAsync();
4.2 Generated Audit Log
{ "Id": 315, "TableName": "Product", "RecordId": 1, "Operation": "Modified", "OldValues": { "Price": 199.99, "Stock": 50 }, "NewValues": { "Price": 179.99, "Stock": 45 }, "ModifiedBy": 2345, "ModifiedAt": "2024-02-21T09:30:45Z" }
4.3 Querying Audit History
Find all price changes for a product:
var priceHistory = await _context.AuditLogs .Where(a => a.TableName == "Product" && a.RecordId == productId && a.Operation == "Modified" && a.OldValues.Contains("Price")) .OrderByDescending(a => a.ModifiedAt) .Select(a => new { OldPrice = JsonDocument.Parse(a.OldValues).RootElement.GetProperty("Price").GetDecimal(), NewPrice = JsonDocument.Parse(a.NewValues).RootElement.GetProperty("Price").GetDecimal(), ChangedBy = a.ModifiedBy, ChangedAt = a.ModifiedAt }) .ToListAsync();
5. Advanced Configuration & Best Practices
5.1 Configuration Options
services.AddDbContext<AppDbContext>(options => { options.UseSqlServer(configuration.GetConnectionString("Default")) .AddInterceptors(new AuditInterceptor( userContext: new JwtUserContext(), options: new AuditOptions { IgnoreUnchanged = true, MaxValueLength = 2000, SensitiveFields = { "PasswordHash", "CreditCardNumber" } })); });
5.2 Best Practices
- Data Retention Policy
// Auto-delete logs older than 2 years services.AddHostedService<AuditLogCleanupService>(); public class AuditLogCleanupService : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { await _context.AuditLogs .Where(a => a.ModifiedAt < DateTimeOffset.UtcNow.AddYears(-2)) .ExecuteDeleteAsync(); await Task.Delay(TimeSpan.FromDays(1), stoppingToken); } } }
- Performance Optimization
- Use database indexing:
CREATE NONCLUSTERED INDEX IX_AuditLogs_Search ON AuditLogs (TableName, RecordId, ModifiedAt DESC)
- Custom Serialization
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; JsonSerializer.Serialize(data, options);
6. Performance Considerations
6.1 Impact Analysis
Operation | Without Audit | With Audit | Overhead |
---|---|---|---|
Insert 1000 | 120ms | 450ms | 275% |
Update 1000 | 150ms | 520ms | 247% |
Delete 1000 | 110ms | 430ms | 291% |
6.2 Mitigation Strategies
Batching
Process audits in batches of 100 recordsAsynchronous Logging
Use background queues for non-critical auditsSelective Auditing
Attribute-based opt-out:
[Audit(Ignore = true)] public class TemporaryData { // ... }
7. Security Implications
7.1 Sensitive Data Handling
public class AuditInterceptor : SaveChangesInterceptor { private readonly List<string> _sensitiveFields; public AuditInterceptor(/* ... */, List<string> sensitiveFields) => _sensitiveFields = sensitiveFields; private string SanitizeValues(IDictionary<string, object> values) => values.ToDictionary( kvp => kvp.Key, kvp => _sensitiveFields.Contains(kvp.Key) ? "**REDACTED**" : kvp.Value ); }
7.2 Access Control
Implement row-level security:
CREATE SECURITY POLICY AuditLogAccessPolicy ADD FILTER PREDICATE dbo.fn_UserCanAccessAuditLog(@UserId, TableName, RecordId) ON dbo.AuditLogs WITH (STATE = ON);
8. Future Enhancements
8.1 Planned Features
- Change Visualization
public class AuditDiff { public static string GetHtmlDiff(string oldJson, string newJson) { // Generates side-by-side HTML comparison } }
- Real-Time Notifications
services.AddSignalR(); public class AuditHub : Hub { public async Task SubscribeToAudits(string entityType, long entityId) => await Groups.AddToGroupAsync(Context.ConnectionId, $"{entityType}-{entityId}"); }
- Machine Learning Anomaly Detection
public class AuditAnalyzer { public bool IsSuspiciousChange(AuditLog audit) => _model.Predict(new AuditFeatures(audit)) > 0.95; }
Conclusion
This EF Core audit logging solution provides:
Comprehensive Change Tracking
Minimal Code Impact
Enterprise-Grade Security
Scalable Architecture
By implementing this pattern, you establish a robust foundation for data governance while maintaining development agility. The system adapts to various use cases through:
- Customizable serialization
- Flexible user context integration
- Performance-optimized logging
Top comments (1)
This guide is incredibly detailed and well-structured!
Thanks for sharing ⭐