Implementing Audit Logs in EF Core Without Polluting Your Entities

Most modern applications require the historical audit logging of changes made to database entities. The audit log provides you with insights into all changes made, including their timestamps and the users responsible for those changes. The logs ensure transparency, allowing stakeholders to observe what changes were made to business-critical data, when they were made, and who made them. Besides, many regulatory compliances require applications to maintain a historical trail. Today, I will show you how to add audit logs to an Entity Framework Core (EF Core) project neatly and dynamically.

Implementing Audit Logs in EF Core Without Polluting Your Entities

What is Audit logging?

Audit logs (or audit trails) are detailed, chronological records of business activities and events. These historical data are essential for maintaining visibility, enhancing debugging, meeting compliance, and ensuring accountability. Audit trails are necessary for the following reasons.

Compliance with industry regulations

Regulations such as CIS, DSS, SOC 2, and PCI often require robust audit logging. To meet these compliance standards, companies must implement detailed logging as a key requirement.

Identifying and fixing errors

The audit trail contains detailed information about each transaction, clearly identifying which values were changed and when they occurred. Such information helps determine where the data was updated incorrectly or made inconsistent. For instance, if a student complains that their basic information is incorrect in the school system, the Administrators can trace who inserted the wrong data and when it occurred.

Preserving Data Integrity

Logs also act as a revert mechanism for accidental changes, helping maintain system reliability.

Enhancing Security Through Audit Insights

Reviewing audit logs enables organisations to examine in detail who did what, how, and when. They can easily pinpoint any unauthorized access, suspicious patterns, or misuse of privilege. This activity provides a clear picture of gaps and risks in the current setup and recommends new security procedures.

Implementing Audit Logs in EF Core

After understanding the importance of logs, let's explore how to add them to your EF Core-based project. I will use the example of an ASP.NET Core API with PostgreSQL as the data source. The project represents a job portal that connects applicants, hiring companies, and job postings.

Step 1: Create a project

dotnet new webapi -n JobAppApi
cd JobAppApi

Step 2: Install the necessary NuGet packages

dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Step 3: Define Base Auditable Interface

public interface IAuditableEntity
{
    DateTimeOffset CreatedAtUtc { get; set; }
    DateTimeOffset? UpdatedAtUtc { get; set; }
    string CreatedBy { get; set; }
    string? UpdatedBy { get; set; }
}

These properties need to be present in all of the implementations of this interface. While you can argue that this actually pollutes the entities and therefore conflicts with the title of this post, many companies have these properties on all entities anyway. If you prefer not to have the properties on all entities and still get audit logging to work, you can look into EF Core's shadow properties feature.

Step 4: Define Data models

Applicant to represent the job candidate


public class Applicant : IAuditableEntity
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string Skill { get; set; } = string.Empty;
    public Guid UserId { get; set; } 

    public DateTimeOffset CreatedAtUtc { get; set; }
    public DateTimeOffset? UpdatedAtUtc { get; set; }
    public string CreatedBy { get; set; } = null!;
    public string? UpdatedBy { get; set; }
    
    [ForeignKey(nameof(UserId))]
    public virtual User User { get; set; }  
}

A Company model that creates job posts


public class Company: IAuditableEntity
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string Industry { get; set; } = string.Empty;

    public Guid UserId { get; set; } 
    public DateTimeOffset CreatedAtUtc { get; set; }
    public DateTimeOffset? UpdatedAtUtc { get; set; }
    public string CreatedBy { get; set; } = null!;
    public string? UpdatedBy { get; set; }
    
    [ForeignKey(nameof(UserId))]
    public virtual User User { get; set; }  
}

JobPost representing a job


public class JobPost : IAuditableEntity
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string Title { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public Guid CompanyId { get; set; }
    public Company Company { get; set; } = null!;

    public DateTimeOffset CreatedAtUtc { get; set; }
    public DateTimeOffset? UpdatedAtUtc { get; set; }
    public string CreatedBy { get; set; } = null!;
    public string? UpdatedBy { get; set; }
}

A common user to log in


public class User: IAuditableEntity
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;

    public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
    public DateTimeOffset? UpdatedAtUtc { get; set; } 
    public string CreatedBy { get; set; } = null!;
    public string? UpdatedBy { get; set; }
    public virtual ICollection<Applicant> Applicants { get; set; }
    public virtual ICollection<Company> Companies { get; set; }
}

You can notice that we are defining CreatedAtUtc, UpdatedAtUtc, CreatedBy and UpdatedBy while they reside in the IAuditableEntity interface. Interfaces are contracts, not the implementations. Hence, we need to define these properties in the implementing class.

For this post, we will store the user's password in clear-text. In a real system you should never do this. Since the post is already quite long, I have decided to do this for simplicity. Always hash and salt passwords using a proven algorithm such as PBKDF2, bcrypt, or ASP.NET Core's PasswordHasher<TUser>.

Other models for API input

public class ApplicantDtoInp
{
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
    public string Skill { get; set; } = string.Empty;
}
public class CompanyDtoInp
{
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
    public string Industry { get; set; } = string.Empty;
}
public class JobPostDtoInp
{
    public string Title { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public Guid CompanyId { get; set; }
}

Step 5: Configure the Database Context

public class ApplicationDbContext: DbContext
{
    public DbSet<Applicant> Applicants => Set<Applicant>();
    public DbSet<Company> Companies => Set<Company>();
    public DbSet<JobPost> JobPosts => Set<JobPost>();
    public DbSet<User> Users => Set<User>();

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

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
    }
}

Step 6: Add PostgreSQL configuration in Program.cs

Inject the DBcontext and connection string in Program.cs


var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
    var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")!;
    var dataSourceBuilder = new Npgsql.NpgsqlDataSourceBuilder(connectionString);
    dataSourceBuilder.EnableDynamicJson();
    options.UseNpgsql(dataSourceBuilder.Build());
});

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
    context.Database.Migrate(); // This applies any pending migrations
}

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
// You would normally call UseHsts for production

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

Calling context.Database.Migrate(); applies any pending migrations at startup. This is an alternative to running dotnet ef database update manually.

Step 7: Add Connection string in appsettings.json

"ConnectionStrings":
  {
    "DefaultConnection": "Host=localhost;Port=5433;Database=jobAppDb;Username=postgres;Password=1234"
  },

For this post, we'll add the connection string directly to the appsettings.json file. For a real system, you should look into secrets, environment variables, or Azure Key Vault if you are hosting on Azure.

Step 8: Run migrations

We have models ready. Now creating all the tables in the database using Code First migration.

dotnet ef migrations add InitialCreate

Step 9: Implement Auditing Enums and Classes

So far, we have created a basic ASP.NET Core API. Let's add audit functionalities to the project.

public enum TrailType : byte
{
    None = 0,
    Create = 1,
    Update = 2,
    Delete = 3
}

AuditTrail model

public class AuditTrail
{
    public Guid Id { get; set; }
    public Guid? UserId { get; set; }
    public string EntityName { get; set; } = null!;
    public string? PrimaryKey { get; set; }
    public TrailType TrailType { get; set; }
    public DateTimeOffset DateUtc { get; set; }

    public Dictionary<string, object?> OldValues { get; set; } = [];
    public Dictionary<string, object?> NewValues { get; set; } = [];
    public List<string> ChangedColumns { get; set; } = [];
}

Configure AuditTrail to the DbContext

public class AuditTrailConfiguration : IEntityTypeConfiguration<AuditTrail>
{
    public void Configure(EntityTypeBuilder<AuditTrail> builder)
    {
        builder.ToTable("audit_trails");
        builder.HasKey(e => e.Id);
        builder.HasIndex(e => e.EntityName);

        builder.Property(e => e.Id);
        builder.Property(e => e.UserId);
        builder.Property(e => e.EntityName).HasMaxLength(100).IsRequired();
        builder.Property(e => e.PrimaryKey).HasMaxLength(100);
        builder.Property(e => e.DateUtc).IsRequired();
        builder.Property(e => e.TrailType).HasConversion<string>();

        builder.Property(e => e.OldValues).HasColumnType("jsonb");
        builder.Property(e => e.NewValues).HasColumnType("jsonb");
        builder.Property(e => e.ChangedColumns).HasColumnType("jsonb");
    }
}

Add DbSet in the DbContext

public DbSet<AuditTrail> AuditTrails => Set<AuditTrail>();

Step 10: Configure JWT Authentication in the project

As we aim to insert the userId in the trail, so user authentication is a necessary step to identify the user from the request context while APIs are authorized.

Configure JWT values in appsettings.json

"JwtSettings": 
{
    "SecretKey": "this_is_super_secret_key_please_change_it",
    "Issuer": "yourapp.com",
    "Audience": "yourapp.com",
    "ExpiryMinutes": 60
},

Then add JwtSettings model to get JWT values

public class JwtSettings
{
    public string SecretKey { get; set; } = string.Empty;
    public string Issuer { get; set; } = string.Empty;
    public string Audience { get; set; } = string.Empty;
    public int ExpiryMinutes { get; set; }
}

Inject JWT authorization in Program.cs

using System.Text;
using JobAppApi.Models;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;

var jwtSettings = builder.Configuration.GetSection("JwtSettings");
builder.Services.Configure<JwtSettings>(jwtSettings);
var secretKey = jwtSettings["SecretKey"]!;
var key = Encoding.ASCII.GetBytes(secretKey);

// --- AUTHENTICATION ---
builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    options.RequireHttpsMetadata = false;
    options.SaveToken = true;
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = jwtSettings["Issuer"],
        ValidAudience = jwtSettings["Audience"],
        IssuerSigningKey = new SymmetricSecurityKey(key)
        // The options above are enough for the sample to work. For a real system,
        // look into setting RequireSignedTokens, ClockSkew, NameClaimType, and RoleClaimType
    };
});

// --- SWAGGER ---
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo { Title = "Job Application API", Version = "v1" });
    // Enable Authorization in Swagger
    var securityScheme = new OpenApiSecurityScheme
    {
        Name = "Authorization",
        Description = "Enter 'Bearer {your JWT token}'",
        In = ParameterLocation.Header,
        Type = SecuritySchemeType.Http,
        Scheme = "bearer",
        BearerFormat = "JWT",
        Reference = new OpenApiReference
        {
            Type = ReferenceType.SecurityScheme,
            Id = "Bearer"
        }
    };
    c.AddSecurityDefinition("Bearer", securityScheme);
    c.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        { securityScheme, Array.Empty<string>() }
    });
});

builder.Services.AddAuthorization();

app.UseAuthentication();

Add a LoginRequest class for API input


public class LoginRequest
{
    public string Email { get; set; } = string.Empty;
    public string Password { get; set; } = string.Empty;
}

And finally, add an AuthController for login

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using JobAppApi.Data;
using JobAppApi.Models;
using JobAppApi.Models.Dtos;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

namespace JobAppApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
    private readonly JwtSettings _jwtSettings;
    private readonly ApplicationDbContext _context;

    public AuthController(IOptions<JwtSettings> jwtOptions, ApplicationDbContext context)
    {
        _context = context;
        _jwtSettings = jwtOptions.Value;
    }

    [HttpPost("login")]
    public async Task<IActionResult> Login([FromBody] LoginRequest request)
    {
        var result = await _context.Users
            .Where(x => 
                    x.Email == request.Email
                    && x.Password == request.Password
                )
            .FirstOrDefaultAsync();

        if (result is null)
            return Unauthorized();
        
        var tokenHandler = new JwtSecurityTokenHandler();
        var key = Encoding.ASCII.GetBytes(_jwtSettings.SecretKey);

        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(new[]
            {
                new Claim(ClaimTypes.NameIdentifier, result.Id.ToString()),
                new Claim(ClaimTypes.Name, result.Email)
            }),
            Expires = DateTimeOffset.UtcNow.AddMinutes(_jwtSettings.ExpiryMinutes),
            Issuer = _jwtSettings.Issuer,
            Audience = _jwtSettings.Audience,
            SigningCredentials = new SigningCredentials(
                new SymmetricSecurityKey(key),
                SecurityAlgorithms.HmacSha256Signature)
        };

        var token = tokenHandler.CreateToken(tokenDescriptor);
        var tokenString = tokenHandler.WriteToken(token);

        return Ok(new { Token = tokenString });
    }
}

Step 11: Add Applicant, Company and JobPost Controllers

Now creating Auth-protected controllers for all the operations

using JobAppApi.Data;
using JobAppApi.Models;
using JobAppApi.Models.Dtos;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace JobAppApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ApplicantsController : ControllerBase
{
    private readonly ApplicationDbContext _context;

    public ApplicantsController(ApplicationDbContext context)
    {
        _context = context;
    }

    [HttpGet]
    [Authorize]
    public async Task<ActionResult<List<Applicant>>> GetAll() => await _context.Applicants.ToListAsync();

    [HttpPost]
    public async Task<ActionResult<Applicant>> Create(ApplicantDtoInp input)
    {
        var applicant = new Applicant()
        {
            Skill = input.Skill,
            User = new User()
            {
                Email = input.Email,
                Name = input.Name,
                Password = input.Password,
            }
        };
        _context.Applicants.Add(applicant);
        await _context.SaveChangesAsync();
        
        return StatusCode(StatusCodes.Status201Created);
    }
}
using JobAppApi.Data;
using JobAppApi.Models;
using JobAppApi.Models.Dtos;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace JobAppApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public class CompanyController : ControllerBase
{
    private readonly ApplicationDbContext _context;

    public CompanyController(ApplicationDbContext context)
    {
        _context = context;
    }

    [HttpGet]
    [Authorize]
    public async Task<ActionResult<List<Company>>> GetAll() => await _context.Companies.ToListAsync();

    [HttpPost]
    public async Task<ActionResult<Company>> Create(CompanyDtoInp input)
    {
        var company = new Company()
        {
            Industry = input.Industry,
            User = new User()
            {
                Email = input.Email,
                Name = input.Name,
                Password = input.Password,
            }
        };
        _context.Companies.Add(company);
        await _context.SaveChangesAsync();
        return StatusCode(StatusCodes.Status201Created);
    }
}
using JobAppApi.Data;
using JobAppApi.Models;
using JobAppApi.Models.Dtos;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace JobAppApi.Controllers;

[ApiController]
[Route("api/[controller]")]
public class JobPostController : ControllerBase
{
    private readonly ApplicationDbContext _context;

    public JobPostController(ApplicationDbContext context)
    {
        _context = context;
    }

    [HttpGet]
    [Authorize]
    public async Task<ActionResult<List<JobPost>>> GetAll() => await _context.JobPosts.Include(j => j.Company).ToListAsync();

    [HttpPost]
    public async Task<ActionResult<JobPost>> Create(JobPostDtoInp jobPost)
    {
        var company = await _context.Companies.FindAsync(jobPost.CompanyId);
        if (company == null)
        {
            return BadRequest("Invalid CompanyId");
        }

        _context.JobPosts.Add(
                new JobPost()
                {
                    Title = jobPost.Title,
                    Description = jobPost.Description,
                    CompanyId = jobPost.CompanyId,
                }
            );
        
        await _context.SaveChangesAsync();
        
        return StatusCode(StatusCodes.Status201Created);
    }
}

Step 12: Defining the context provider to get the HTTP userId from the updates request

Defining an interface and a class to fetch the UserId from the HttpContextAccessor

public interface ICurrentSessionProvider
{
    Guid? GetUserId();
}
public class CurrentSessionProvider: ICurrentSessionProvider
{
    private readonly Guid? _currentUserId;

    public CurrentSessionProvider(IHttpContextAccessor accessor)
    {
        var userId = accessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
        if (Guid.TryParse(userId, out var id))
            _currentUserId = id;
    }

    public Guid? GetUserId() => _currentUserId;
}

Step 13: Register new services in the Program.cs

builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentSessionProvider, CurrentSessionProvider>();

Step 14: Override DbContext's default SaveChangesAsync method

Modify the SaveChangesAsync to integrate audit trail creation for every Create, Update, and Delete operation.

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    var userId = _sessionProvider.GetUserId();

    SetAuditableProperties(userId);
    var auditEntries = HandleAuditingBeforeSaveChanges(userId);

    if (auditEntries.Any())
        await AuditTrails.AddRangeAsync(auditEntries, cancellationToken);

    return await base.SaveChangesAsync(cancellationToken);
}

private void SetAuditableProperties(Guid? userId)
{
    const string system = "system";
    foreach (var entry in ChangeTracker.Entries<IAuditableEntity>())
    {
        if (entry.State == EntityState.Added)
        {
            entry.Entity.CreatedAtUtc = DateTimeOffset.UtcNow;
            entry.Entity.CreatedBy = userId?.ToString() ?? system;
        }
        else if (entry.State == EntityState.Modified)
        {
            entry.Entity.UpdatedAtUtc = DateTimeOffset.UtcNow;
            entry.Entity.UpdatedBy = userId?.ToString() ?? system;
        }
    }
}

private List<AuditTrail> HandleAuditingBeforeSaveChanges(Guid? userId)
{
    var entries = ChangeTracker.Entries<IAuditableEntity>()
        .Where(e => e.State == EntityState.Added || e.State == EntityState.Modified || e.State == EntityState.Deleted);

    var auditTrails = new List<AuditTrail>();

    foreach (var entry in entries)
    {
        var audit = new AuditTrail
        {
            Id = Guid.NewGuid(),
            UserId = userId,
            EntityName = entry.Entity.GetType().Name,
            DateUtc = DateTimeOffset.UtcNow
        };

        foreach (var prop in entry.Properties)
        {
            if (prop.Metadata.IsPrimaryKey())
            {
                audit.PrimaryKey = prop.CurrentValue?.ToString();
                continue;
            }

            if (prop.Metadata.Name.Equals("PasswordHash")) continue;

            var name = prop.Metadata.Name;

            switch (entry.State)
            {
                case EntityState.Added:
                    audit.TrailType = TrailType.Create;
                    audit.NewValues[name] = prop.CurrentValue;
                    break;
                case EntityState.Deleted:
                    audit.TrailType = TrailType.Delete;
                    audit.OldValues[name] = prop.OriginalValue;
                    break;
                case EntityState.Modified:
                    if (!Equals(prop.OriginalValue, prop.CurrentValue))
                    {
                        audit.TrailType = TrailType.Update;
                        audit.ChangedColumns.Add(name);
                        audit.OldValues[name] = prop.OriginalValue;
                        audit.NewValues[name] = prop.CurrentValue;
                    }
                    break;
            }
        }

        if (audit.TrailType != TrailType.None)
            auditTrails.Add(audit);
    }

    return auditTrails;
}

I override SaveChangesAsync to track the userIds of users who perform add or update operations. Also, it prepares an audit trail before calling the base SaveChangesAsync method. In HandleAuditingBeforeSaveChanges, we retrieve all the changed values by navigating the entity's properties.

Step 15: Run the code and test

Swagger UI will show the different endpoints that we created.

Swagger UI

First, let us create a new company. If the user authentication is not present, then the system is set for createdBy

Create company
Create company response

We can check that the record was created using the GET endpoint.

GET company endpoints
Company was created

We have successfully created a user. Now, check the log trail.

audit_trails tables
Show rows
Old and new values

The record is logged in the table. Let's log in with the user.

Login
Login response

We have successfully logged in with the user. Let's create JobPost next.

Create job post
Get job post
Get job post response

In the database, you can see that CreatedBy is automatically set by the HTTP request context.

Created by set on JobPost

And in the audit_trails table we see the following.

Audit trail

With this login session, create a new applicant.

Create applicant
Create applicant response

And call the GET endpoint to see the created applicant.

Get applicants
Get applicants response

As expected, the applicant is in the Applicants table in the database.

Applicants table

And the audit_trails table now contains the following.

audit_trails table
audit_trails table

In the JobAppApi project, we have implemented and tested an audit trail for Create operations. We can similarly add trails for Update and Delete operations of any entity. However, it is highly recommended to use the soft delete operation rather than the actual EF Core delete method. You can get a complete guide on how to implement soft delete neatly and dynamically. You can find the full code of JobAppApi here.

Compliance

Before we round things off, I want to put a few words on compliance. I have taken some shortcuts in this post to keep things simple and tried to focus on the technical solution. As already mentioned, passwords are stored in clear text. Plus, the audit logs contain personally identifiable information (PII), which can cause a compliance risk (GDPR, HIPAA, etc.). You could introduce an AuditIgnore attribute or similar, to keep certain pieces of information out of the audit_trails table.

Conclusion

Audit logging is a crucial aspect of any application, serving both legal and technical purposes. It enables the company to view when and what changes are made to the application, as well as who made these changes. Such insights not only help in debugging but also in identifying shortcomings in the security and authorization of the system. Besides, many legal bodies, such as CIS and DSS, include detailed logging. I shared a step-by-step solution for implementing logging in an EF Core-based project, utilizing an API project.

elmah.io: Error logging and Uptime Monitoring for your web apps

This blog post is brought to you by elmah.io. elmah.io is error logging, uptime monitoring, deployment tracking, and service heartbeats for your .NET and JavaScript applications. Stop relying on your users to notify you when something is wrong or dig through hundreds of megabytes of log files spread across servers. With elmah.io, we store all of your log messages, notify you through popular channels like email, Slack, and Microsoft Teams, and help you fix errors fast.

See how we can help you monitor your website for crashes Monitor your website