Serilog in Production: Multi-File Logging with PII Sanitization
Logging is one of those things every developer knows they should do well but few actually invest time in getting right. In our regulated API, logging isn't just for debugging — it's a regulatory requirement. We need audit trails, error tracking, performance monitoring, and security event logs. But we also handle personally identifiable information (PII) that must never appear in log files. Here's how I configured Serilog to handle all of this with five dedicated log files and automatic PII sanitization.
The Five-File Strategy
Instead of one monolithic log file, we split logs by purpose:
csharp// Program.cs
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("System", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithProperty("Application", "PlatformAPI")
// 1. General application log
.WriteTo.File(
path: "logs/application-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] [{RequestId}] {Message:lj}{NewLine}{Exception}")
// 2. Error-only log for alerting
.WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e => e.Level >= LogEventLevel.Error)
.WriteTo.File(
path: "logs/errors-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 90,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] [{RequestId}] {SourceContext} {Message:lj}{NewLine}{Exception}"))
// 3. Security events
.WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e =>
e.Properties.ContainsKey("SecurityEvent"))
.WriteTo.File(
path: "logs/security-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 365,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] [{RequestId}] [SEC] {Message:lj}{NewLine}"))
// 4. Performance metrics
.WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e =>
e.Properties.ContainsKey("ElapsedMs"))
.WriteTo.File(
path: "logs/performance-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} {RequestId} {Method} {Path} {StatusCode} {ElapsedMs}ms{NewLine}"))
// 5. Audit trail
.WriteTo.Logger(lc => lc
.Filter.ByIncludingOnly(e =>
e.Properties.ContainsKey("AuditEvent"))
.WriteTo.File(
path: "logs/audit-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 365 * 2, // 2 years for compliance
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{RequestId}] [AUDIT] UserId={UserId} Action={AuditAction} {Message:lj}{NewLine}"))
.CreateLogger();
Each file has a different retention policy:
- Application logs: 30 days — general debugging, rotated aggressively
- Error logs: 90 days — longer retention for incident investigation
- Security logs: 1 year — regulatory requirement for security events
- Performance logs: 30 days — used for trend analysis
- Audit logs: 2 years — compliance requirement for financial transactions
Daily Rolling Configuration
The RollingInterval.Day setting creates a new file each day with the date appended: application-20250301.log, application-20250302.log, etc. Combined with retainedFileCountLimit, old files are automatically deleted.
But there's a subtlety most tutorials miss — file size limits. A busy API can generate gigabytes of logs per day. We add file size rolling on top of daily rolling:
csharp.WriteTo.File(
path: "logs/application-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
fileSizeLimitBytes: 100_000_000, // 100 MB per file
rollOnFileSizeLimit: true, // Create application-20250301_001.log
outputTemplate: "...")
PII Sanitization: The Destructuring Approach
The most critical aspect of our logging setup is PII sanitization. We can't have account numbers, national IDs, or phone numbers appearing in log files. Even with encrypted storage, PII in logs is a compliance violation.
I use a custom Serilog destructuring policy:
csharp// Logging/PiiSanitizingDestructuringPolicy.cs
public class PiiSanitizingDestructuringPolicy : IDestructuringPolicy
{
private static readonly HashSet<string> PiiProperties = new(
StringComparer.OrdinalIgnoreCase)
{
"Password", "Token", "RefreshToken", "AccessToken",
"NationalId", "Ssn", "TaxId",
"AccountNumber", "Iban", "CardNumber", "Cvv", "Pin",
"PhoneNumber", "MobileNumber", "Phone",
"Email", "EmailAddress",
"FirstName", "LastName", "FullName",
"Address", "Street", "City",
"DateOfBirth", "BirthDate",
};
public bool TryDestructure(
object value,
ILogEventPropertyValueFactory propertyValueFactory,
out LogEventPropertyValue? result)
{
result = null;
if (value == null) return false;
var type = value.GetType();
if (type.IsPrimitive || type == typeof(string)) return false;
var properties = type.GetProperties(
BindingFlags.Public | BindingFlags.Instance);
var sanitizedProps = new List<LogEventProperty>();
foreach (var prop in properties)
{
var propValue = prop.GetValue(value);
if (PiiProperties.Contains(prop.Name))
{
var masked = MaskValue(prop.Name, propValue?.ToString());
sanitizedProps.Add(new LogEventProperty(
prop.Name,
propertyValueFactory.CreatePropertyValue(masked)));
}
else
{
sanitizedProps.Add(new LogEventProperty(
prop.Name,
propertyValueFactory.CreatePropertyValue(
propValue, destructureObjects: true)));
}
}
result = new StructureValue(sanitizedProps);
return true;
}
private static string MaskValue(string propertyName, string? value)
{
if (string.IsNullOrEmpty(value)) return "***";
return propertyName.ToLower() switch
{
"email" or "emailaddress" =>
MaskEmail(value), // u***@example.com
"phonenumber" or "mobilenumber" or "phone" =>
MaskPhone(value), // +90*****4567
"accountnumber" or "iban" =>
MaskAccount(value), // ******7890
"cardnumber" =>
MaskCard(value), // ****-****-****-1234
_ => "***REDACTED***"
};
}
private static string MaskEmail(string email)
{
var parts = email.Split('@');
if (parts.Length != 2) return "***@***";
return $"{parts[0][0]}***@{parts[1]}";
}
private static string MaskPhone(string phone)
{
if (phone.Length < 4) return "***";
return phone[..3] + new string('*', phone.Length - 7) + phone[^4..];
}
private static string MaskAccount(string account)
{
if (account.Length < 4) return "***";
return new string('*', account.Length - 4) + account[^4..];
}
private static string MaskCard(string card)
{
var digits = new string(card.Where(char.IsDigit).ToArray());
if (digits.Length < 4) return "****";
return $"****-****-****-{digits[^4..]}";
}
}
Register it in the configuration:
csharpLog.Logger = new LoggerConfiguration()
.Destructure.With<PiiSanitizingDestructuringPolicy>()
// ... rest of configuration
.CreateLogger();
Now when you log a user object:
csharp_logger.LogInformation("User authenticated: {@User}", user);
// Output:
// User authenticated: { UserId: "usr_123", Email: "u***@example.com",
// PhoneNumber: "+90*****4567", NationalId: "***REDACTED***",
// Role: "Customer", LastLogin: "2025-03-01T10:00:00Z" }
Non-PII fields (UserId, Role, LastLogin) pass through unchanged. PII fields are automatically masked.
Security Event Logging
For security events, we use a dedicated logger extension that adds the SecurityEvent property for filtering:
csharppublic static class SecurityLogExtensions
{
public static void LogSecurityEvent(
this ILogger logger,
string eventType,
string description,
object? details = null)
{
using (LogContext.PushProperty("SecurityEvent", true))
using (LogContext.PushProperty("SecurityEventType", eventType))
{
logger.LogWarning(
"Security Event [{EventType}]: {Description} {@Details}",
eventType, description, details);
}
}
}
// Usage in middleware
_logger.LogSecurityEvent(
"BRUTE_FORCE_DETECTED",
"Multiple failed login attempts",
new { IpAddress = remoteIp, AttemptCount = 5, UserId = userId });
This event automatically routes to both logs/security-*.log (via the SecurityEvent filter) and logs/application-*.log (since it meets the minimum level).
Audit Trail for Compliance
Financial transactions require an immutable audit trail:
csharppublic class AuditLogger
{
private readonly ILogger<AuditLogger> _logger;
public void LogTransaction(
string userId,
string action,
decimal amount,
string fromAccount,
string toAccount)
{
using (LogContext.PushProperty("AuditEvent", true))
using (LogContext.PushProperty("UserId", userId))
using (LogContext.PushProperty("AuditAction", action))
{
_logger.LogInformation(
"Transaction: {Action} Amount={Amount} " +
"From={FromAccount} To={ToAccount}",
action,
amount,
MaskAccount(fromAccount),
MaskAccount(toAccount));
}
}
}
// Output in audit log:
// 2025-03-01 10:30:15.123 [req_abc123] [AUDIT] UserId=usr_456
// Action=TRANSFER Transaction: TRANSFER Amount=1500.00
// From=******7890 To=******3456
Notice that even in audit logs, account numbers are masked. The audit trail records who did what and when, but the full account numbers live only in the database, protected by role-based access.
Performance Logging Middleware
Our performance log captures request timing automatically:
csharppublic class PerformanceLoggingMiddleware
{
public async Task InvokeAsync(HttpContext context)
{
var sw = Stopwatch.StartNew();
await _next(context);
sw.Stop();
using (LogContext.PushProperty("ElapsedMs", sw.ElapsedMilliseconds))
using (LogContext.PushProperty("Method", context.Request.Method))
using (LogContext.PushProperty("Path", context.Request.Path))
using (LogContext.PushProperty("StatusCode", context.Response.StatusCode))
{
if (sw.ElapsedMilliseconds > 1000)
{
_logger.LogWarning(
"Slow request: {Method} {Path} completed in {ElapsedMs}ms",
context.Request.Method,
context.Request.Path,
sw.ElapsedMilliseconds);
}
else
{
_logger.LogInformation(
"Request: {Method} {Path} completed in {ElapsedMs}ms",
context.Request.Method,
context.Request.Path,
sw.ElapsedMilliseconds);
}
}
}
}
Requests over 1 second are logged as warnings, making them easy to filter in monitoring tools. The performance log file gives us a clean CSV-like format for analysis.
Lessons from Production
After running this configuration for a year, key lessons emerged. First, log volume management is critical — without file size limits, our busiest day generated 12GB of application logs. The 100MB file size limit keeps individual files manageable. Second, PII leaks come from unexpected places. We originally missed ToString() overrides on entity classes that included sensitive fields. The destructuring policy catches these at the serialization boundary. Third, structured logging pays dividends. Because we use Serilog's structured format ({@User} instead of string interpolation), we can query logs programmatically when investigating incidents. Fourth, separate files for separate concerns means an operations team member investigating a performance issue doesn't need to wade through debug messages, and an auditor reviewing compliance logs sees only what they need.
The investment in proper logging infrastructure is one of those decisions that doesn't feel urgent until the first production incident where you need it. By then, it's too late to add retroactively. Build it right from the start.