Skip to main content
Version: v1.2.7 (latest)

Audit Trail Examples

This section shows common usage patterns and scenarios for the audit trail feature.

Example 1: Basic Audit Trail Setup

Minimal setup for development and testing:

var builder = WebApplication.CreateBuilder(args);

var auditDir = Path.Combine(AppContext.BaseDirectory, "audit-trail");
Directory.CreateDirectory(auditDir);

builder.Services.AddEventPublisher(o =>
{
o.Source = new Uri("https://orders.example.com");
})
.AddAuditTrail(audit => audit.UseNDJson(options =>
{
options.DirectoryPath = auditDir;
options.MaxFileSizeBytes = 10 * 1024 * 1024;
options.RollInterval = TimeSpan.FromMinutes(5);
options.MaxFileCount = 20;
}));

// Also register the reader for querying
builder.Services.AddNDJsonAuditTrailQuerying(options =>
{
options.DirectoryPath = auditDir;
});

var app = builder.Build();

app.MapPost("/api/orders", async (IEventPublisher publisher, CreateOrderCommand cmd) =>
{
await publisher.PublishAsync(new OrderPlaced
{
OrderId = Guid.NewGuid(),
CustomerId = cmd.CustomerId,
Total = cmd.Total
});

return Results.Ok();
});

app.Run();

Example 2: Compliance-Ready Configuration

Production setup with long retention and secure storage:

var builder = WebApplication.CreateBuilder(args);

// Secure audit directory with restricted permissions
var auditDir = builder.Configuration["AuditTrail:DirectoryPath"]
?? "/secure/audit/hermodr";

if (!Directory.Exists(auditDir))
{
Directory.CreateDirectory(auditDir);

// Set restrictive permissions (Unix-like systems)
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{
var fileInfo = new FileInfo(Path.Combine(auditDir, ".gitkeep"));
// Only owner can read/write
fileInfo.UnixFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
}
}

builder.Services.AddEventPublisher(o =>
{
o.Source = new Uri("https://orders.example.com");
o.DataSchemaBaseUri = new Uri("https://schemas.example.com/");
})
.AddAuditTrail(audit => audit.UseNDJson(options =>
{
options.DirectoryPath = auditDir;
options.MaxFileSizeBytes = 50 * 1024 * 1024; // 50 MB
options.RollInterval = TimeSpan.FromHours(12);
options.MaxFileCount = 730; // Keep ~1 year at 2 rolls/day
}));

// Register reader for compliance queries
builder.Services.AddNDJsonAuditTrailQuerying(options =>
{
options.DirectoryPath = auditDir;
});

var app = builder.Build();
app.Run();

Example 3: Multi-Tenant Audit Trail

Separate audit trails per tenant:

public class TenantAwareAuditTrailMiddleware : IEventMiddleware
{
private readonly ITenantResolver _tenantResolver;
private readonly IServiceProvider _services;

public TenantAwareAuditTrailMiddleware(
ITenantResolver tenantResolver,
IServiceProvider services)
{
_tenantResolver = tenantResolver;
_services = services;
}

public async Task InvokeAsync(EventContext context, Func<Task> next)
{
var tenant = await _tenantResolver.ResolveAsync(context);

// Get tenant-specific audit writer
var writer = _services.GetRequiredKeyedService<IAuditTrailWriter>(tenant.Id);

// Record event to tenant-specific audit trail
await writer.WriteAsync(context.Event);

await next();
}
}

// Registration
builder.Services
.AddEventPublisher()
.AddAuditTrail(audit => audit.UseNDJson(options =>
{
options.DirectoryPath = "/audit/tenant-a";
}), key: "tenant-a")
.AddAuditTrail(audit => audit.UseNDJson(options =>
{
options.DirectoryPath = "/audit/tenant-b";
}), key: "tenant-b");

Example 4: Audit Trail Query API

Full-featured query endpoint:

public static class AuditTrailEndpoints
{
public static void MapAuditTrailEndpoints(this IEndpointRouteBuilder app)
{
// Query audit trail with filters
app.MapGet("/api/audit-trail", async (
IAuditTrailReader<AuditTrailEntry> reader,
string? eventType, string? source, string? subject,
DateTimeOffset? from, DateTimeOffset? to, int? limit) =>
{
var query = new AuditTrailStreamQuery
{
EventType = eventType,
Source = source,
Subject = subject,
From = from,
To = to,
Limit = limit ?? 100
};

var entries = new List<AuditTrailEntry>();
var max = query.Limit ?? 100;

await foreach (var entry in reader.ReadAsync(query))
{
if (entries.Count >= max) break;
entries.Add(entry);
}

return Results.Ok(entries);
})
.WithName("QueryAuditTrail")
.WithOpenApi();

// Get entries for a specific event
app.MapGet("/api/audit-trail/event/{eventId}", async (
string eventId,
IAuditTrailReader<AuditTrailEntry> reader) =>
{
var query = new AuditTrailStreamQuery { Limit = 1000 };

await foreach (var entry in reader.ReadAsync(query))
{
if (entry.EventId == eventId)
{
return Results.Ok(entry);
}
}

return Results.NotFound();
})
.WithName("GetAuditTrailEntry")
.WithOpenApi();

// Get all events of a specific type
app.MapGet("/api/audit-trail/type/{eventType}", async (
string eventType,
IAuditTrailReader<AuditTrailEntry> reader,
int? limit) =>
{
var query = new AuditTrailStreamQuery
{
EventType = eventType,
Limit = limit ?? 100
};

var entries = new List<AuditTrailEntry>();
await foreach (var entry in reader.ReadAsync(query))
{
entries.Add(entry);
if (entries.Count >= query.Limit) break;
}

return Results.Ok(entries);
})
.WithName("GetEventsByType")
.WithOpenApi();

// Get aggregate statistics
app.MapGet("/api/audit-trail/stats", async (
IAuditTrailReader<AuditTrailEntry> reader,
DateTimeOffset? from, DateTimeOffset? to) =>
{
var query = new AuditTrailStreamQuery
{
From = from ?? DateTimeOffset.UtcNow.AddHours(-24),
To = to ?? DateTimeOffset.UtcNow
};

var eventTypeCounts = new Dictionary<string, int>();
var totalCount = 0;

await foreach (var entry in reader.ReadAsync(query))
{
totalCount++;

if (!eventTypeCounts.TryGetValue(entry.EventType, out var count))
{
count = 0;
}
eventTypeCounts[entry.EventType] = count + 1;
}

return Results.Ok(new
{
From = query.From,
To = query.To,
TotalEvents = totalCount,
EventsByType = eventTypeCounts,
GeneratedAt = DateTimeOffset.UtcNow
});
})
.WithName("GetAuditTrailStats")
.WithOpenApi();
}
}

// Usage in Program.cs
var app = builder.Build();
app.MapAuditTrailEndpoints();
app.Run();

Example 5: Archival Process

Move old audit files to cold storage:

public class AuditTrailArchiver
{
private readonly string _sourceDirectory;
private readonly string _archiveDirectory;
private readonly ILogger<AuditTrailArchiver> _logger;

public AuditTrailArchiver(
string sourceDirectory,
string archiveDirectory,
ILogger<AuditTrailArchiver> logger)
{
_sourceDirectory = sourceDirectory;
_archiveDirectory = archiveDirectory;
_logger = logger;
}

public async Task ArchiveOldFilesAsync(int retentionDays = 90)
{
var cutoffDate = DateTimeOffset.UtcNow.AddDays(-retentionDays);
var files = Directory.GetFiles(_sourceDirectory, "*.ndjson")
.Select(f => new FileInfo(f))
.Where(f => f.LastWriteTime < cutoffDate)
.OrderBy(f => f.LastWriteTime)
.ToList();

foreach (var file in files)
{
var archivePath = Path.Combine(
_archiveDirectory,
$"archive-{file.Name}");

_logger.LogInformation("Archiving {File} to {ArchivePath}",
file.Name, archivePath);

// Move to archive (or upload to cloud storage)
File.Move(file.FullName, archivePath);

// Optionally compress
// await CompressFileAsync(archivePath);
}

_logger.LogInformation("Archived {Count} files", files.Count);
}
}

// Register as hosted service
builder.Services.AddHostedService<ArchivalBackgroundService>();

public class ArchivalBackgroundService : BackgroundService
{
private readonly AuditTrailArchiver _archiver;
private readonly ILogger<ArchivalBackgroundService> _logger;

public ArchivalBackgroundService(
AuditTrailArchiver archiver,
ILogger<ArchivalBackgroundService> logger)
{
_archiver = archiver;
_logger = logger;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Run archival daily at 2 AM
var now = DateTimeOffset.UtcNow;
var nextRun = now.Hour == 2 ?
now.AddDays(1).Date.AddHours(2) :
now.Date.AddHours(2);

var delay = nextRun - now;
_logger.LogInformation("Next archival run in {Delay}", delay);

await Task.Delay(delay, stoppingToken);

await _archiver.ArchiveOldFilesAsync(retentionDays: 90);
}
catch (Exception ex)
{
_logger.LogError(ex, "Archival failed");
}
}
}
}