Implementing Activity Logging with Custom Attributes
Build a clean, non-intrusive activity logging system in .NET Core using custom attributes to track user actions with minimal code changes.
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.
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.
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.
Comments
Comments are GitHub discussions. Sign in with GitHub to post; reactions need no account.