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

Querying the Audit Trail

The audit trail provides stream-based querying capabilities that are efficient, non-blocking, and scalable.

IAuditTrailReader

The read interface for audit trail entries:

public interface IAuditTrailReader<TEntry>
{
IAsyncEnumerable<TEntry> ReadAsync(
AuditTrailStreamQuery query,
CancellationToken cancellationToken = default);
}

Registration

NDJSON Backend

The reader is registered independently from the writer:

builder.Services.AddNDJsonAuditTrailQuerying(options =>
{
options.DirectoryPath = auditDir;
});

This allows the reader and writer to:

  • Live in separate processes
  • Scale independently
  • Use different configurations

EF Core Backend

builder.Services.AddAuditTrailQuerying(opts =>
opts.UseSqlServer(connectionString));

AuditTrailStreamQuery

The query object for filtering audit entries:

public class AuditTrailStreamQuery
{
public string? EventType { get; set; } // Filter by event type (wildcards supported)
public string? Source { get; set; } // Filter by event source
public string? Subject { get; set; } // Filter by event subject
public DateTimeOffset? From { get; set; } // Filter entries after this time
public DateTimeOffset? To { get; set; } // Filter entries before this time
public int? Limit { get; set; } // Maximum entries to return
}

Query Examples

Basic Queries

All Entries

var reader = provider.GetRequiredService<IAuditTrailReader<AuditTrailEntry>>();

await foreach (var entry in reader.ReadAsync(new AuditTrailStreamQuery()))
{
Console.WriteLine($"{entry.Timestamp}: {entry.EventType}");
}

By Event Type

var query = new AuditTrailStreamQuery
{
EventType = "order.placed"
};

await foreach (var entry in reader.ReadAsync(query))
{
Console.WriteLine($"Order placed: {entry.Data}");
}

By Time Range

var query = new AuditTrailStreamQuery
{
From = DateTimeOffset.UtcNow.AddHours(-1),
To = DateTimeOffset.UtcNow
};

await foreach (var entry in reader.ReadAsync(query))
{
Console.WriteLine($"{entry.Timestamp}: {entry.EventType}");
}

By Subject

var query = new AuditTrailStreamQuery
{
Subject = "order/456"
};

await foreach (var entry in reader.ReadAsync(query))
{
Console.WriteLine($"Order 456 event: {entry.EventType}");
}

Advanced Queries

Wildcard Event Type

var query = new AuditTrailStreamQuery
{
EventType = "order.*" // Matches order.placed, order.confirmed, etc.
};

Combined Filters

var query = new AuditTrailStreamQuery
{
EventType = "payment.*",
Source = "https://payments.example.com",
From = DateTimeOffset.UtcNow.AddDays(-7),
Limit = 100
};

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

Console.WriteLine($"Found {entries.Count} payment events");

With Limit

var query = new AuditTrailStreamQuery
{
EventType = "order.placed",
Limit = 10 // Return at most 10 entries
};

Querying in ASP.NET Core

Minimal API Example

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);
});

Query by Event ID

app.MapGet("/api/audit-trail/event/{eventId}", async (
string eventId,
IAuditTrailReader<AuditTrailEntry> reader) =>
{
var query = new AuditTrailStreamQuery
{
Limit = 1000 // Reasonable upper bound
};

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

return Results.NotFound();
});

Query by Event 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);
});

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>();

await foreach (var entry in reader.ReadAsync(query))
{
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 = eventTypeCounts.Values.Sum(),
EventsByType = eventTypeCounts
});
});

Performance Considerations

Streaming vs Loading

The audit trail reader uses streaming — entries are read one at a time, not loaded into memory:

// ✅ GOOD: Streaming, memory-efficient
await foreach (var entry in reader.ReadAsync(query))
{
Process(entry);
}

// ❌ BAD: Loads all entries into memory
var allEntries = await reader.ReadAsync(query).ToListAsync();

Filtering Early

Apply filters in the query, not after loading:

// ✅ GOOD: Filter in query
var query = new AuditTrailStreamQuery
{
EventType = "order.placed",
From = DateTimeOffset.UtcNow.AddHours(-1)
};

// ❌ BAD: Filter after loading
var allEntries = await reader.ReadAsync(new AuditTrailStreamQuery()).ToListAsync();
var filtered = allEntries
.Where(e => e.EventType == "order.placed")
.Where(e => e.Timestamp > DateTimeOffset.UtcNow.AddHours(-1))
.ToList();

Use Limits

Always specify a Limit for unbounded queries:

// ✅ GOOD: Bounded query
var query = new AuditTrailStreamQuery
{
EventType = "order.*",
Limit = 1000
};

// ❌ BAD: Unbounded query could return millions of entries
var query = new AuditTrailStreamQuery
{
EventType = "order.*"
};

EF Core Querying

When using the EF Core backend, you get full LINQ support:

using var db = provider.GetRequiredService<AuditTrailDbContext>();

// Complex query with aggregations
var stats = await db.AuditTrailEntries
.Where(e => e.Timestamp > DateTimeOffset.UtcNow.AddHours(-24))
.GroupBy(e => e.EventType)
.Select(g => new
{
EventType = g.Key,
Count = g.Count(),
FirstOccurrence = g.Min(e => e.Timestamp),
LastOccurrence = g.Max(e => e.Timestamp)
})
.OrderByDescending(x => x.Count)
.ToListAsync();