Serilog with noise filtering and a simple daily report
Most apps already log something. The problem is that the useful events are buried under framework chatter, duplicated request lines, and low-value noise. The goal is not more logs. The goal is logs you can trust during an incident, and logs you can use later for reporting.
This article shows a clean Serilog setup in .NET 10, plus two ways to generate a daily transaction report.
What good looks like
A log event should answer:
- What happened (event name, message template)
- Where it happened (service, environment, route, handler)
- Who or what was involved (anonymised user id, tenant, request id, UPRN)
- Can I correlate it (correlation id, trace id)
- Can I count it (consistent event names and properties)
If you nail those, debugging gets faster and reporting becomes cheap.
Step 1: Add packages
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Settings.Configuration
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
Optional, if you want SQL storage:
dotnet add package Serilog.Sinks.MSSqlServer
Step 2: Configure Serilog in Program.cs (noise-aware)
This baseline enriches events with request context, filters common low value paths, turns down Microsoft logs while keeping warnings and errors, and writes to console plus a daily rolling file.
using Serilog;
using Serilog.Context;
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, services, loggerConfig) =>
{
loggerConfig
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", builder.Environment.ApplicationName)
.Enrich.WithProperty("Environment", builder.Environment.EnvironmentName)
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("System", Serilog.Events.LogEventLevel.Warning)
.Filter.ByExcluding(le =>
{
if (!le.Properties.TryGetValue("RequestPath", out var pathValue)) return false;
var path = pathValue.ToString().Trim('"');
return path.StartsWith("/health", StringComparison.OrdinalIgnoreCase)
|| path.Equals("/favicon.ico", StringComparison.OrdinalIgnoreCase);
})
.WriteTo.Console()
.WriteTo.File(
path: "Logs/log-.ndjson",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
shared: true
);
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddControllers();
var app = builder.Build();
app.Use(async (ctx, next) =>
{
var incoming = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var cid)
? cid.ToString()
: ctx.TraceIdentifier;
using (LogContext.PushProperty("CorrelationId", incoming))
using (LogContext.PushProperty("RequestPath", ctx.Request.Path.Value ?? ""))
using (LogContext.PushProperty("RequestMethod", ctx.Request.Method))
{
await next();
}
});
app.UseSerilogRequestLogging(opts =>
{
opts.EnrichDiagnosticContext = (diagCtx, httpCtx) =>
{
diagCtx.Set("ClientIp", httpCtx.Connection.RemoteIpAddress?.ToString());
diagCtx.Set("UserAgent", httpCtx.Request.Headers.UserAgent.ToString());
};
});
app.MapControllers();
app.Run();
Step 3: Keep appsettings.json readable
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{ "Name": "Console" },
{
"Name": "File",
"Args": {
"path": "Logs/log-.ndjson",
"rollingInterval": "Day",
"retainedFileCountLimit": 30,
"shared": true
}
}
],
"Enrich": [ "FromLogContext" ],
"Properties": {
"Service": "MyService"
}
}
}
Step 4: Log events you can count later
Avoid vague logs like this:
logger.LogInformation("User searched for something");
Prefer consistent events with stable properties:
using Microsoft.AspNetCore.Mvc;
using Serilog;
[ApiController]
[Route("api/search")]
public class SearchController : ControllerBase
{
[HttpGet]
public IActionResult Get([FromQuery] string postcode)
{
var normalised = postcode?.Trim().ToUpperInvariant();
Log.ForContext("EventName", "AddressSearch")
.ForContext("Postcode", normalised)
.Information("Address search requested");
// Do work...
Log.ForContext("EventName", "AddressSearchCompleted")
.ForContext("Postcode", normalised)
.ForContext("ResultCount", 12)
.Information("Address search completed");
return Ok(new { results = 12 });
}
}
With that, reporting can count EventName = AddressSearchCompleted without parsing message text.
Step 5: Two approaches for a daily report
Option A: Report from SQL (good for dashboards)
If you want a report page inside your app, SQL tends to be the easiest.
Add an MSSQL sink:
// Requires Serilog.Sinks.MSSqlServer
// loggerConfig.WriteTo.MSSqlServer(
// connectionString: context.Configuration.GetConnectionString("LogsDb"),
// sinkOptions: new Serilog.Sinks.MSSqlServer.Sinks.MSSqlServerSinkOptions
// {
// TableName = "log_events",
// AutoCreateSqlTable = true
// });
Then query daily counts:
SELECT
CAST([TimeStamp] AS date) AS [Day],
COUNT(*) AS [TotalCompletedSearches]
FROM dbo.log_events
WHERE Properties LIKE '%"EventName":"AddressSearchCompleted"%'
GROUP BY CAST([TimeStamp] AS date)
ORDER BY [Day] DESC;
This works as a quick start. If you want it cleaner long term, store EventName as a dedicated column using additional column options, and query that column instead of searching inside Properties.
Option B: Report from rolling files (fast, no database)
If you write daily NDJSON files, you can read yesterday’s file and count events.
using System.Text.Json;
public static class DailyLogReport
{
public static int CountEvents(string filePath, string eventName)
{
if (!File.Exists(filePath)) return 0;
var count = 0;
foreach (var line in File.ReadLines(filePath))
{
if (string.IsNullOrWhiteSpace(line)) continue;
using var doc = JsonDocument.Parse(line);
if (!doc.RootElement.TryGetProperty("Properties", out var props)) continue;
if (!props.TryGetProperty("EventName", out var ev)) continue;
if (ev.GetString() == eventName) count++;
}
return count;
}
}
Usage:
var date = DateTime.UtcNow.Date.AddDays(-1);
var path = $"Logs/log-{date:yyyyMMdd}.ndjson";
var total = DailyLogReport.CountEvents(path, "AddressSearchCompleted");
Console.WriteLine($"Completed searches for {date:yyyy-MM-dd}: {total}");
Filtering tips that pay off
- Do not log every request twice. If you use
UseSerilogRequestLogging, you usually do not need extra request logging middleware. - Keep Microsoft logs at Warning. Info-level framework logs can be huge.
- Filter paths you never investigate (health, favicon, static assets).
- Be careful with PII. Do not log full addresses, names, or free text from users. Hash stable identifiers if you need repeat detection.
A checklist you can copy into your README
- Serilog wired at host level (captures startup errors)
- Consistent EventName property for reportable actions
- Correlation id present on every request
- Microsoft and System logs overridden to Warning
- Health and other low value paths filtered
- Rolling files retained with limits
- PII review done for every logged property
- Daily report query or file parser implemented
Wrap up
Once you have structured logs, filtering, and an event naming convention, you stop reading logs and start using logs. Incidents get easier to diagnose, and basic reporting becomes a byproduct of normal engineering.