The first version of audit logging in the Contact Management Application was what most codebases end up with: _logger.LogInformation("Creating contact...") copy-pasted into every controller action. It worked, until someone forgot one, and the one they forgot was of course the action the auditors asked about. Logging that depends on developer discipline isn’t an audit trail; it’s a suggestion.
So I moved the whole concern out of the actions. A one-line [ActivityLog("Creating new Contact")] attribute marks what an endpoint does, and a middleware does the actual capture: who, what, from which IP, with which HTTP method. Controllers stay clean, and adding logging to a new endpoint is a declaration, not an implementation.
What you’ll learn#
How the Activity Logging Middleware works.
The role of the ActivityLogAttribute in logging actions.
Capturing and persisting metadata like IP address, HTTP method, and user agent.
How to store and manage these logs.
1. Overview of Activity Logging Middleware#
Activity Logging Middleware captures user activities by intercepting incoming HTTP requests and checking for the presence of a custom ActivityLogAttribute on the endpoint. If the attribute is present, the middleware logs detailed information about the request, such as:
User ID: Identifies who performed the action.
Activity: A description of the action (e.g., “Creating a new contact”).
Endpoint: The API route that was accessed.
HTTP method: The request type (e.g., GET, POST).
IP address: The origin of the request.
User agent: The client making the request.
2. Implementing the Activity Logging Middleware#
The ActivityLoggingMiddleware captures and logs user actions using the ActivityLogAttribute attached to specific endpoints.
2.1 Activity Logging Middleware Implementation#
public class ActivityLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ActivityLoggingMiddleware> _logger;
private readonly IServiceProvider _serviceProvider;
public ActivityLoggingMiddleware(RequestDelegate next, ILogger<ActivityLoggingMiddleware> logger, IServiceProvider serviceProvider)
{
_next = next;
_logger = logger;
_serviceProvider = serviceProvider;
}
public async Task InvokeAsync(HttpContext context)
{
var endpoint = context.GetEndpoint();
var activityAttribute = endpoint?.Metadata.GetMetadata<ActivityLogAttribute>();
if (activityAttribute != null)
{
using (var scope = _serviceProvider.CreateScope())
{
var activityLogService = scope.ServiceProvider.GetRequiredService<IActivityLogService>();
var userIdClaim = context.User.FindFirst("id");
var userId = userIdClaim != null ? Guid.Parse(userIdClaim.Value) : Guid.Empty;
var activityDescription = activityAttribute.ActivityDescription;
var endpointPath = context.Request.Path;
var httpMethod = context.Request.Method;
var ipAddress = context.Connection.RemoteIpAddress?.ToString();
var userAgent = context.Request.Headers["User-Agent"].ToString();
var logEntry = new ActivityLogEntry
{
UserId = userId,
Activity = activityDescription,
Endpoint = endpointPath,
HttpMethod = httpMethod,
Timestamp = DateTimeOffset.UtcNow,
IpAddress = ipAddress,
UserAgent = userAgent
};
await activityLogService.LogActivityAsync(logEntry);
}
}
await _next(context);
}
}Note the middleware resolves IActivityLogService from a scope it creates per request. Middleware is constructed once at startup, so injecting a scoped service through the constructor would fail; creating the scope inside InvokeAsync is the standard way around that.
2.2 Middleware Flow#
Intercept the request: The middleware checks if the endpoint has an attached ActivityLogAttribute.
Create a logging scope: A scoped instance of IActivityLogService is used to log the action.
Capture user and request metadata: The middleware captures details like user ID, IP address, HTTP method, and user agent.
Log the activity: A new ActivityLogEntry is created and stored.
await _next(context) runs, so an action that throws or returns 400 is still logged as if it happened. If your auditors need outcomes, capture the entry first, run the pipeline, then persist with the response status code after _next completes.3. ActivityLog Attribute for Describing Actions#
The ActivityLogAttribute marks specific actions that need to be logged. It carries a description of the action, such as “Creating a new contact” or “Updating contact details.”
3.1 Defining the ActivityLog Attribute#
[AttributeUsage(AttributeTargets.Method)]
public class ActivityLogAttribute : Attribute
{
public string ActivityDescription { get; }
public ActivityLogAttribute(string activityDescription)
{
ActivityDescription = activityDescription;
}
}This attribute is attached to API methods to specify what action is being performed, and that description is then logged by the middleware. The attribute itself contains no logic on purpose: it is pure metadata, which keeps it trivial to test and impossible to break.
4. Applying Activity Logging in the ContactPersonController#
To turn on logging for specific API actions, apply the ActivityLogAttribute to the corresponding controller methods.
4.1 Example Controller with ActivityLogAttribute#
[ApiController]
[Route("api/[controller]")]
public class ContactPersonController : ControllerBase
{
private readonly IContactPersonService _contactPersonService;
public ContactPersonController(IContactPersonService contactPersonService)
{
_contactPersonService = contactPersonService;
}
[HttpPost]
[ActivityLog("Creating new Contact")]
public async Task<IActionResult> Add(CreateContactPerson createContactPerson)
{
var createdContactPerson = await _contactPersonService.Add(createContactPerson);
return CreatedAtAction(nameof(GetById), new { id = createdContactPerson.Id }, createdContactPerson);
}
[HttpPut]
[ActivityLog("Updating Contact")]
public async Task<IActionResult> Update(UpdateContactPerson updateContactPerson)
{
var updatedContactPerson = await _contactPersonService.Update(updateContactPerson);
return Ok(updatedContactPerson);
}
[HttpDelete("{id}")]
[ActivityLog("Deleting Contact")]
public async Task<IActionResult> Delete(Guid id)
{
var deleted = await _contactPersonService.Delete(id);
if (!deleted) return NotFound();
return NoContent();
}
}Each API method carries an ActivityLogAttribute describing the action: Creating, Updating, or Deleting a contact. The action bodies contain zero logging code, which is the whole point.
5. Storing Activity Logs#
To persist the activity logs, a database or an external logging service can be used. In this case, the logs are stored in a database using an ActivityLogEntry entity.
5.1 ActivityLogEntry Entity#
public class ActivityLogEntry
{
public Guid UserId { get; set; }
public required string Activity { get; set; }
public required string Endpoint { get; set; }
public required string HttpMethod { get; set; }
public DateTimeOffset Timestamp { get; set; }
public required string IpAddress { get; set; }
public required string UserAgent { get; set; }
}5.2 Example of Logging Activity#
public class ActivityLogService : IActivityLogService
{
private readonly IActivityLogRepository _activityLogRepository;
public ActivityLogService(IActivityLogRepository activityLogRepository)
{
_activityLogRepository = activityLogRepository;
}
public async Task LogActivityAsync(ActivityLogEntry logEntry)
{
await _activityLogRepository.AddAsync(logEntry);
}
}The activity logs are stored via a service that adds the ActivityLogEntry to the database for future retrieval and auditing.
6. What this buys you, honestly#
The real win is that logging stops depending on discipline: any endpoint with the attribute gets a consistent record of who did what and from where, which covers most security-audit and compliance conversations I’ve been in.
The trade-offs are just as real. Every logged request costs a database write on the hot path (consider a queue if volume grows), and the middleware records intent, not outcome, per the watch-out above. Log the mutating endpoints; logging reads usually produces noise nobody queries.
Takeaways#
- Cross-cutting concerns belong in the pipeline, not in action bodies. An attribute plus middleware means a forgotten log line can’t happen silently.
- Attributes should stay metadata-only; the middleware owns the behavior. That split keeps both halves testable.
- Middleware can’t constructor-inject scoped services; create a scope inside
InvokeAsync. - Decide up front if the audit trail should record attempts or outcomes. This implementation logs attempts; moving the persist call after
_nextchanges the answer.
Next step: pull the Contact Management Application, hit the contact endpoints, and query the activity log table to watch entries appear. Then try the outcome-logging variant: move the persist after _next(context) and record context.Response.StatusCode.
