Skip to content

Commit 0c05ff7

Browse files
authored
feat: add Serilog logging instrumentation (#1110)
## Summary Implements comprehensive logging infrastructure using Serilog (Feature #6): - **LoggingConfig**: Configurable log level, file output, JSON format, async mode - **SerilogFactory**: Creates Microsoft.Extensions.Logging.ILoggerFactory instances - **LoggerExtensions**: CallerMemberName-based `LogMethodEntry`/`LogMethodExit` helpers - **ActivityEnricher**: Adds correlation IDs (TraceId/SpanId) from System.Diagnostics.Activity - **SensitiveDataScrubbingPolicy**: Automatically redacts strings in Production environment - **EnvironmentDetector**: Environment-based security decisions - **LoggingConstants**: Centralized magic values for maintainability - **TestLoggerFactory**: XUnit test output integration ### Features - Console output to stderr (Warning+ by default) - File logging with daily rotation, 100MB limit, 30 file retention - JSON format option for log aggregation systems - Async logging option for better service performance - Automatic sensitive data scrubbing in Production ### Documentation - Added `docs/CONFIGURATION.md` with complete configuration reference - Updated `configuration-example.json` with logging section - Marked feature 00006 as DONE ## Test Plan - [x] 95 new tests covering all logging components - [x] Test coverage: 84.83% (above 80% threshold) - [x] All 637 tests pass (0 skipped) - [x] `format.sh` passes - [x] `build.sh` passes with 0 warnings, 0 errors - [x] `coverage.sh` passes
1 parent 5836215 commit 0c05ff7

38 files changed

+2704
-103
lines changed

docs

Submodule docs updated from 09132cd to 03362c9

src/Core/Config/AppConfig.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Text.Json.Serialization;
33
using KernelMemory.Core.Config.Cache;
44
using KernelMemory.Core.Config.Validation;
5+
using KernelMemory.Core.Logging;
56

67
namespace KernelMemory.Core.Config;
78

@@ -36,6 +37,13 @@ public sealed class AppConfig : IValidatable
3637
[JsonPropertyName("search")]
3738
public SearchConfig? Search { get; set; }
3839

40+
/// <summary>
41+
/// Logging configuration settings
42+
/// Controls log level, file output, and format
43+
/// </summary>
44+
[JsonPropertyName("logging")]
45+
public LoggingConfig? Logging { get; set; }
46+
3947
/// <summary>
4048
/// Validates the entire configuration tree
4149
/// </summary>

src/Core/Core.csproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@
1010
<PackageReference Include="cuid.net" />
1111
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
1212
<PackageReference Include="Parlot" />
13+
<!-- Logging - Serilog -->
14+
<PackageReference Include="Serilog" />
15+
<PackageReference Include="Serilog.Extensions.Logging" />
16+
<PackageReference Include="Serilog.Sinks.Console" />
17+
<PackageReference Include="Serilog.Sinks.File" />
18+
<PackageReference Include="Serilog.Sinks.Async" />
19+
<PackageReference Include="Serilog.Enrichers.Span" />
20+
<PackageReference Include="Serilog.Formatting.Compact" />
1321
</ItemGroup>
1422

1523
<ItemGroup>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Diagnostics;
4+
using Serilog.Core;
5+
using Serilog.Events;
6+
7+
namespace KernelMemory.Core.Logging;
8+
9+
/// <summary>
10+
/// Serilog enricher that adds correlation IDs from System.Diagnostics.Activity.
11+
/// Provides TraceId and SpanId properties for distributed tracing and log correlation.
12+
/// </summary>
13+
public sealed class ActivityEnricher : ILogEventEnricher
14+
{
15+
/// <summary>
16+
/// Property name for the trace ID (from Activity.TraceId).
17+
/// </summary>
18+
public const string TraceIdPropertyName = "TraceId";
19+
20+
/// <summary>
21+
/// Property name for the span ID (from Activity.SpanId).
22+
/// </summary>
23+
public const string SpanIdPropertyName = "SpanId";
24+
25+
/// <summary>
26+
/// Enriches the log event with Activity correlation IDs if available.
27+
/// </summary>
28+
/// <param name="logEvent">The log event to enrich.</param>
29+
/// <param name="propertyFactory">Factory for creating log event properties.</param>
30+
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
31+
{
32+
var activity = Activity.Current;
33+
if (activity == null)
34+
{
35+
return;
36+
}
37+
38+
// Add TraceId for correlating logs across the entire operation
39+
var traceId = activity.TraceId.ToString();
40+
if (!string.IsNullOrEmpty(traceId) && traceId != LoggingConstants.EmptyTraceId)
41+
{
42+
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(TraceIdPropertyName, traceId));
43+
}
44+
45+
// Add SpanId for correlating logs within a specific span
46+
var spanId = activity.SpanId.ToString();
47+
if (!string.IsNullOrEmpty(spanId) && spanId != LoggingConstants.EmptySpanId)
48+
{
49+
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(SpanIdPropertyName, spanId));
50+
}
51+
}
52+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
namespace KernelMemory.Core.Logging;
4+
5+
/// <summary>
6+
/// Detects the current runtime environment from environment variables.
7+
/// Environment detection is critical for security decisions like sensitive data scrubbing.
8+
/// </summary>
9+
public static class EnvironmentDetector
10+
{
11+
/// <summary>
12+
/// Gets the current environment name.
13+
/// Checks DOTNET_ENVIRONMENT first, falls back to ASPNETCORE_ENVIRONMENT,
14+
/// then defaults to Development if neither is set.
15+
/// </summary>
16+
/// <returns>The environment name (e.g., "Development", "Production", "Staging").</returns>
17+
public static string GetEnvironment()
18+
{
19+
// Check DOTNET_ENVIRONMENT first (takes precedence)
20+
var dotNetEnv = Environment.GetEnvironmentVariable(LoggingConstants.DotNetEnvironmentVariable);
21+
if (!string.IsNullOrWhiteSpace(dotNetEnv))
22+
{
23+
return dotNetEnv;
24+
}
25+
26+
// Fall back to ASPNETCORE_ENVIRONMENT
27+
var aspNetEnv = Environment.GetEnvironmentVariable(LoggingConstants.AspNetCoreEnvironmentVariable);
28+
if (!string.IsNullOrWhiteSpace(aspNetEnv))
29+
{
30+
return aspNetEnv;
31+
}
32+
33+
// Default to Development for safety (full logging)
34+
return LoggingConstants.DefaultEnvironment;
35+
}
36+
37+
/// <summary>
38+
/// Checks if the current environment is Production.
39+
/// In Production, sensitive data is scrubbed from logs.
40+
/// </summary>
41+
/// <returns>True if running in Production environment.</returns>
42+
public static bool IsProduction()
43+
{
44+
return string.Equals(
45+
GetEnvironment(),
46+
LoggingConstants.ProductionEnvironment,
47+
StringComparison.OrdinalIgnoreCase);
48+
}
49+
50+
/// <summary>
51+
/// Checks if the current environment is Development.
52+
/// In Development, full logging is enabled for debugging.
53+
/// </summary>
54+
/// <returns>True if running in Development environment.</returns>
55+
public static bool IsDevelopment()
56+
{
57+
return string.Equals(
58+
GetEnvironment(),
59+
LoggingConstants.DefaultEnvironment,
60+
StringComparison.OrdinalIgnoreCase);
61+
}
62+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Runtime.CompilerServices;
4+
using Microsoft.Extensions.Logging;
5+
6+
namespace KernelMemory.Core.Logging;
7+
8+
/// <summary>
9+
/// Extension methods for ILogger providing CallerMemberName-based method logging.
10+
/// These helpers automatically capture the calling method name for entry/exit logging.
11+
/// </summary>
12+
public static class LoggerExtensions
13+
{
14+
/// <summary>
15+
/// Logs method entry at Debug level with automatic method name capture.
16+
/// Use this at the beginning of public methods for diagnostics.
17+
/// </summary>
18+
/// <param name="logger">The logger instance.</param>
19+
/// <param name="param1">Optional first parameter to log.</param>
20+
/// <param name="param2">Optional second parameter to log.</param>
21+
/// <param name="param3">Optional third parameter to log.</param>
22+
/// <param name="methodName">Automatically captured method name (do not pass explicitly).</param>
23+
public static void LogMethodEntry(
24+
this ILogger logger,
25+
object? param1 = null,
26+
object? param2 = null,
27+
object? param3 = null,
28+
[CallerMemberName] string methodName = "")
29+
{
30+
// Skip logging if Debug level is disabled for performance
31+
if (!logger.IsEnabled(LogLevel.Debug))
32+
{
33+
return;
34+
}
35+
36+
// Log with structured parameters for queryability
37+
logger.LogDebug(
38+
"{MethodName} called with {Param1}, {Param2}, {Param3}",
39+
methodName,
40+
param1,
41+
param2,
42+
param3);
43+
}
44+
45+
/// <summary>
46+
/// Logs method exit at Debug level with automatic method name capture.
47+
/// Use this before returning from public methods for diagnostics.
48+
/// </summary>
49+
/// <param name="logger">The logger instance.</param>
50+
/// <param name="result">Optional result value to log.</param>
51+
/// <param name="methodName">Automatically captured method name (do not pass explicitly).</param>
52+
public static void LogMethodExit(
53+
this ILogger logger,
54+
object? result = null,
55+
[CallerMemberName] string methodName = "")
56+
{
57+
// Skip logging if Debug level is disabled for performance
58+
if (!logger.IsEnabled(LogLevel.Debug))
59+
{
60+
return;
61+
}
62+
63+
// Log with structured parameters for queryability
64+
logger.LogDebug(
65+
"{MethodName} completed with {Result}",
66+
methodName,
67+
result);
68+
}
69+
}

src/Core/Logging/LoggingConfig.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using Serilog.Events;
4+
5+
namespace KernelMemory.Core.Logging;
6+
7+
/// <summary>
8+
/// Configuration model for the logging system.
9+
/// Stores log level and file path settings that can be loaded from config files or CLI flags.
10+
/// </summary>
11+
public sealed class LoggingConfig
12+
{
13+
/// <summary>
14+
/// Gets or sets the minimum log level for log output.
15+
/// Defaults to Information which provides useful diagnostics.
16+
/// </summary>
17+
public LogEventLevel Level { get; set; } = LogEventLevel.Information;
18+
19+
/// <summary>
20+
/// Gets or sets the file path for file logging.
21+
/// When null or empty, file logging is disabled.
22+
/// Supports both relative paths (from cwd) and absolute paths.
23+
/// </summary>
24+
public string? FilePath { get; set; }
25+
26+
/// <summary>
27+
/// Gets or sets whether to use JSON format for log output.
28+
/// When false, uses human-readable format (default).
29+
/// JSON format is better for log aggregation systems.
30+
/// </summary>
31+
public bool UseJsonFormat { get; set; }
32+
33+
/// <summary>
34+
/// Gets or sets whether to use async logging.
35+
/// When false (default), uses synchronous logging suitable for CLI.
36+
/// Enable for long-running services to improve performance.
37+
/// </summary>
38+
public bool UseAsyncLogging { get; set; }
39+
40+
/// <summary>
41+
/// Gets a value indicating whether file logging is enabled.
42+
/// Returns true when FilePath is set to a non-empty value.
43+
/// </summary>
44+
public bool IsFileLoggingEnabled => !string.IsNullOrWhiteSpace(this.FilePath);
45+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using Serilog.Events;
4+
5+
namespace KernelMemory.Core.Logging;
6+
7+
/// <summary>
8+
/// Centralized constants for the logging system.
9+
/// All magic values related to logging are defined here for maintainability.
10+
/// </summary>
11+
public static class LoggingConstants
12+
{
13+
/// <summary>
14+
/// Default maximum file size before rotation (100MB).
15+
/// Balances history retention with disk usage.
16+
/// </summary>
17+
public const long DefaultFileSizeLimitBytes = 100 * 1024 * 1024;
18+
19+
/// <summary>
20+
/// Default number of log files to retain (30 files).
21+
/// Approximately 1 month of daily logs or ~3GB max storage.
22+
/// </summary>
23+
public const int DefaultRetainedFileCountLimit = 30;
24+
25+
/// <summary>
26+
/// Default minimum log level for file output.
27+
/// Information level provides useful diagnostics without excessive verbosity.
28+
/// </summary>
29+
public const LogEventLevel DefaultFileLogLevel = LogEventLevel.Information;
30+
31+
/// <summary>
32+
/// Default minimum log level for console/stderr output.
33+
/// Only warnings and errors appear on stderr by default.
34+
/// </summary>
35+
public const LogEventLevel DefaultConsoleLogLevel = LogEventLevel.Warning;
36+
37+
/// <summary>
38+
/// Environment variable for .NET runtime environment detection.
39+
/// Takes precedence over ASPNETCORE_ENVIRONMENT.
40+
/// </summary>
41+
public const string DotNetEnvironmentVariable = "DOTNET_ENVIRONMENT";
42+
43+
/// <summary>
44+
/// Fallback environment variable for ASP.NET Core applications.
45+
/// Used when DOTNET_ENVIRONMENT is not set.
46+
/// </summary>
47+
public const string AspNetCoreEnvironmentVariable = "ASPNETCORE_ENVIRONMENT";
48+
49+
/// <summary>
50+
/// Default environment when no environment variable is set.
51+
/// Defaults to Development for developer safety (full logging enabled).
52+
/// </summary>
53+
public const string DefaultEnvironment = "Development";
54+
55+
/// <summary>
56+
/// Production environment name for comparison.
57+
/// Sensitive data is scrubbed only in Production.
58+
/// </summary>
59+
public const string ProductionEnvironment = "Production";
60+
61+
/// <summary>
62+
/// Placeholder text for redacted sensitive data.
63+
/// Used to indicate data was intentionally removed from logs.
64+
/// </summary>
65+
public const string RedactedPlaceholder = "[REDACTED]";
66+
67+
/// <summary>
68+
/// Human-readable output template for log messages.
69+
/// Includes timestamp, level, source context, message, and optional exception.
70+
/// </summary>
71+
public const string HumanReadableOutputTemplate =
72+
"{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}";
73+
74+
/// <summary>
75+
/// Compact output template for console (stderr) output.
76+
/// Shorter format suitable for CLI error reporting.
77+
/// </summary>
78+
public const string ConsoleOutputTemplate =
79+
"{Timestamp:HH:mm:ss} [{Level:u3}] {Message:lj}{NewLine}{Exception}";
80+
81+
/// <summary>
82+
/// Empty trace ID value (32 zeros) used when no Activity is present.
83+
/// Indicates no distributed tracing context is available.
84+
/// </summary>
85+
public const string EmptyTraceId = "00000000000000000000000000000000";
86+
87+
/// <summary>
88+
/// Empty span ID value (16 zeros) used when no Activity is present.
89+
/// Indicates no distributed tracing context is available.
90+
/// </summary>
91+
public const string EmptySpanId = "0000000000000000";
92+
}

0 commit comments

Comments
 (0)