diff --git a/Directory.Packages.props b/Directory.Packages.props
index b2fbbf6..1c8d3c4 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,6 +9,7 @@
+
@@ -30,6 +31,7 @@
+
diff --git a/Shared.Logger.Sample/NLog.config b/Shared.Logger.Sample/NLog.config
new file mode 100644
index 0000000..31b7d0c
--- /dev/null
+++ b/Shared.Logger.Sample/NLog.config
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Shared.Logger.Sample/Program.cs b/Shared.Logger.Sample/Program.cs
new file mode 100644
index 0000000..330496d
--- /dev/null
+++ b/Shared.Logger.Sample/Program.cs
@@ -0,0 +1,63 @@
+using NLog;
+using Shared.Logger.TennantContext;
+using StaticLogger = Shared.Logger.Logger;
+
+Boolean generateHighVolumeLogs = true;
+const Int32 HighVolumeLogEventCount = 1000;
+
+LogManager.Setup().LoadConfigurationFromFile("NLog.config");
+
+NLog.Logger nlogLogger = LogManager.GetLogger("Shared.Logger.Sample");
+StaticLogger.Initialise(nlogLogger, "shared-logger-sample");
+
+// Simulate the tenant context normally populated by the service middleware.
+TenantContext tenantContext = new();
+tenantContext.Initialise(
+ new TenantIdentifiers(
+ EstateId: Guid.Parse("1e543b2a-d100-4ec1-9735-8590aab2c819"),
+ MerchantId: Guid.Parse("579267d9-46a6-4057-8c66-0b34d7494a28")),
+ perTenantLogsEnabled: false);
+tenantContext.SetCorrelationId(CorrelationId.From(Guid.Parse("b0354c7e-b6de-4a27-9a54-715dc3e56566")));
+TenantContext.CurrentTenant = tenantContext;
+
+Guid transactionId = Guid.NewGuid();
+StaticLogger.LogTrace("Starting sample run {runId}", Guid.NewGuid());
+StaticLogger.LogDebug("Loaded {recordCount} records for {tenantName}", 12, "Example Estate");
+StaticLogger.LogInformation("Processed transaction {transactionId} for {amount} {currency}", transactionId, 125.50m, "GBP");
+StaticLogger.LogWarning("Transaction {transactionId} required a retry after {attemptCount} attempts", transactionId, 2);
+
+try
+{
+ throw new InvalidOperationException("Example failure for the structured exception log.");
+}
+catch (InvalidOperationException exception)
+{
+ StaticLogger.LogError(exception, "Could not process transaction {transactionId}", transactionId);
+}
+
+StaticLogger.LogCritical("Sample critical event for {component}", "payment processor");
+
+if (generateHighVolumeLogs)
+{
+ for (Int32 eventNumber = 1; eventNumber <= HighVolumeLogEventCount; eventNumber++)
+ {
+ TenantContext volumeTenantContext = new();
+ volumeTenantContext.Initialise(
+ new TenantIdentifiers(EstateId: Guid.NewGuid(), MerchantId: Guid.NewGuid()),
+ perTenantLogsEnabled: true);
+ volumeTenantContext.SetCorrelationId(CorrelationId.From(Guid.NewGuid()));
+ TenantContext.CurrentTenant = volumeTenantContext;
+
+ StaticLogger.LogInformation(
+ "Volume sample event {eventNumber} of {totalEvents} for transaction {transactionId} with amount {amount}",
+ eventNumber,
+ HighVolumeLogEventCount,
+ Guid.NewGuid(),
+ (eventNumber % 500) + 0.25m);
+ }
+}
+
+TenantContext.CurrentTenant = new TenantContext();
+LogManager.Shutdown();
+
+Console.WriteLine($"Structured log entries written under: {Path.Combine(AppContext.BaseDirectory, "logs")}");
diff --git a/Shared.Logger.Sample/README.md b/Shared.Logger.Sample/README.md
new file mode 100644
index 0000000..5a449a0
--- /dev/null
+++ b/Shared.Logger.Sample/README.md
@@ -0,0 +1,13 @@
+# Shared.Logger sample
+
+This console app demonstrates structured message-template calls through `Shared.Logger` and NLog. NLog reads [NLog.config](NLog.config) and writes JSON lines with event properties included, so template values and tenant/correlation metadata are available as separate fields. The tenant scope controls file routing: the tenant-scoped event goes into its tenant-specific subfolder, while the regular event goes into `logs/shared`.
+
+Run it from the repository root:
+
+```powershell
+dotnet run --project Shared.Logger.Sample
+```
+
+The sample writes under `logs` in the app's output directory. With per-tenant logging enabled, each logger call produces the regular event and the tenant-scoped event, which this config routes separately.
+
+To generate additional volume, set `generateHighVolumeLogs` to `true` near the top of `Program.cs`. It then writes 1,000 more structured events, each with a fresh correlation ID, estate ID, and merchant ID (and each routed to the regular and tenant-specific files); change `HighVolumeLogEventCount` to adjust the batch size.
diff --git a/Shared.Logger.Sample/Shared.Logger.Sample.csproj b/Shared.Logger.Sample/Shared.Logger.Sample.csproj
new file mode 100644
index 0000000..27f5c36
--- /dev/null
+++ b/Shared.Logger.Sample/Shared.Logger.Sample.csproj
@@ -0,0 +1,17 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Shared.Logger/ILogger.cs b/Shared.Logger/ILogger.cs
index 5594c48..0b562ec 100644
--- a/Shared.Logger/ILogger.cs
+++ b/Shared.Logger/ILogger.cs
@@ -1,6 +1,8 @@
namespace Shared.Logger;
using System;
+using System.Globalization;
+using System.Text;
///
///
@@ -20,13 +22,30 @@ public interface ILogger
void LogCritical(Exception exception);
+ void LogCritical(String messageTemplate, // NOSONAR - delegates to the required string-only overload, so it is not recursive.
+ params Object?[] args) => this.LogCritical(FormatMessageTemplate(messageTemplate, args));
+
+ void LogCritical(Exception exception,
+ String messageTemplate,
+ params Object?[] args) => this.LogCritical(FormatMessageTemplate(messageTemplate, args), exception);
+
void LogCritical(String message,
Exception exception);
void LogDebug(String message);
+ void LogDebug(String messageTemplate,
+ params Object?[] args) => this.LogDebug(FormatMessageTemplate(messageTemplate, args));
+
void LogError(Exception exception);
+ void LogError(String messageTemplate,
+ params Object?[] args) => this.LogError(FormatMessageTemplate(messageTemplate, args));
+
+ void LogError(Exception exception,
+ String messageTemplate,
+ params Object?[] args) => this.LogError(FormatMessageTemplate(messageTemplate, args), exception);
+
void LogError(String message);
void LogError(String message,
@@ -34,8 +53,44 @@ void LogError(String message,
void LogInformation(String message);
+ void LogInformation(String messageTemplate,
+ params Object?[] args) => this.LogInformation(FormatMessageTemplate(messageTemplate, args));
+
void LogTrace(String message);
+ void LogTrace(String messageTemplate,
+ params Object?[] args) => this.LogTrace(FormatMessageTemplate(messageTemplate, args));
+
void LogWarning(String message);
-}
\ No newline at end of file
+ void LogWarning(String messageTemplate,
+ params Object?[] args) => this.LogWarning(FormatMessageTemplate(messageTemplate, args));
+
+ private static String FormatMessageTemplate(String messageTemplate, Object?[] args)
+ {
+ if (args == null || args.Length == 0) return messageTemplate;
+ StringBuilder result = new(messageTemplate.Length + args.Length * 8);
+ Int32 argumentIndex = 0;
+ Int32 index = 0;
+ while (index < messageTemplate.Length)
+ {
+ if (messageTemplate[index] == '{' && index + 1 < messageTemplate.Length && messageTemplate[index + 1] == '{')
+ {
+ result.Append("{{"); index += 2; continue;
+ }
+ if (messageTemplate[index] == '{')
+ {
+ Int32 end = messageTemplate.IndexOf('}', index + 1);
+ if (end >= 0 && argumentIndex < args.Length)
+ {
+ result.Append(Convert.ToString(args[argumentIndex++], CultureInfo.InvariantCulture));
+ index = end + 1;
+ continue;
+ }
+ }
+ result.Append(messageTemplate[index++]);
+ }
+ return result.ToString();
+ }
+
+}
diff --git a/Shared.Logger/Logger.cs b/Shared.Logger/Logger.cs
index 0816eae..4e71302 100644
--- a/Shared.Logger/Logger.cs
+++ b/Shared.Logger/Logger.cs
@@ -1,216 +1,252 @@
-using Shared.Logger.TennantContext;
-
namespace Shared.Logger;
-using NLog;
using System;
+using System.Collections;
+using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Text.Json;
+using NLog;
+using Shared.Logger.TennantContext;
[ExcludeFromCodeCoverage]
-public static class Logger {
- private static ILogger LoggerObject;
+public static class Logger
+{
+ private static ILogger? LoggerObject;
public static Boolean IsInitialised { get; set; }
public static void Initialise(NLog.Logger loggerObject,
- String fileName) {
+ String fileName)
+ {
NlogLogger logger = new();
logger.Initialise(loggerObject, fileName);
Logger.Initialise(logger);
}
- public static void Initialise(Microsoft.Extensions.Logging.ILogger loggerObject) {
- MicrosoftLogger logger = new();
- logger.Initialise(loggerObject);
- Logger.Initialise(logger);
+ public static void Initialise(ILogger loggerObject)
+ {
+ Logger.LoggerObject = loggerObject ?? throw new ArgumentNullException(nameof(loggerObject));
+ Logger.IsInitialised = true;
}
- public static void Initialise(ILogger loggerObject) {
- Logger.LoggerObject = loggerObject ?? throw new ArgumentNullException(nameof(loggerObject));
+ public static void LogTrace(String messageTemplate,
+ params Object?[] args)
+ {
+ Logger.ValidateLoggerObject();
+ String sanitizedTemplate = Logger.SanitizeForLog(messageTemplate);
+ Logger.Write((logger, sanitizedArgs) => logger.LogTrace(sanitizedTemplate, sanitizedArgs), true, args);
+ }
- Logger.IsInitialised = true;
+ public static void LogDebug(String messageTemplate,
+ params Object?[] args)
+ {
+ Logger.ValidateLoggerObject();
+ String sanitizedTemplate = Logger.SanitizeForLog(messageTemplate);
+ Logger.Write((logger, sanitizedArgs) => logger.LogDebug(sanitizedTemplate, sanitizedArgs), true, args);
}
- private const String CorrelationIdPropertyName = "correlationId";
- private const String TenantIdPropertyName = "tenantId";
+ public static void LogInformation(String messageTemplate,
+ params Object?[] args)
+ {
+ Logger.ValidateLoggerObject();
+ String sanitizedTemplate = Logger.SanitizeForLog(messageTemplate);
+ Logger.Write((logger, sanitizedArgs) => logger.LogInformation(sanitizedTemplate, sanitizedArgs), true, args);
+ }
- public static void LogCritical(Exception exception) {
- ValidateLoggerObject();
+ public static void LogWarning(String messageTemplate,
+ params Object?[] args)
+ {
+ Logger.ValidateLoggerObject();
+ String sanitizedTemplate = Logger.SanitizeForLog(messageTemplate);
+ Logger.Write((logger, sanitizedArgs) => logger.LogWarning(sanitizedTemplate, sanitizedArgs), true, args);
+ }
- TenantContext tenantContext = TenantContext.CurrentTenant;
+ public static void LogError(String messageTemplate,
+ params Object?[] args)
+ {
+ Logger.ValidateLoggerObject();
+ Logger.Write((logger, sanitizedArgs) => logger.LogError(messageTemplate, sanitizedArgs), true, args);
+ }
- if (tenantContext == null) {
- LoggerObject.LogCritical(exception);
- return;
- }
- using (ScopeContext.PushProperty(CorrelationIdPropertyName, $"Correlation ID: {tenantContext.CorrelationId.ToString()}")) {
- // Write to the normal log
- LoggerObject.LogCritical(exception);
-
- if (tenantContext.PerTenantLogsEnabled && tenantContext.EstateId != Guid.Empty) {
- // Write to the tenant log
- using (ScopeContext.PushProperty(TenantIdPropertyName, $"_{tenantContext.EstateId.ToString()}")) {
- LoggerObject.LogCritical(exception);
- }
- }
- }
-
+ public static void LogError(Exception exception,
+ String messageTemplate,
+ params Object?[] args)
+ {
+ Logger.ValidateLoggerObject();
+ Logger.Write((logger, sanitizedArgs) => logger.LogError(exception, messageTemplate, sanitizedArgs), true, args);
}
- public static void LogDebug(String message) {
- ValidateLoggerObject();
- TenantContext tenantContext = TenantContext.CurrentTenant;
+ public static void LogCritical(String messageTemplate,
+ params Object?[] args)
+ {
+ Logger.ValidateLoggerObject();
+ Logger.Write((logger, sanitizedArgs) => logger.LogCritical(messageTemplate, sanitizedArgs), true, args);
+ }
- if (tenantContext == null) {
- LoggerObject.LogDebug(message);
- return;
- }
+ public static void LogCritical(Exception exception,
+ String messageTemplate,
+ params Object?[] args)
+ {
+ Logger.ValidateLoggerObject();
+ Logger.Write((logger, sanitizedArgs) => logger.LogCritical(exception, messageTemplate, sanitizedArgs), true, args);
+ }
- using (ScopeContext.PushProperty(CorrelationIdPropertyName, $"Correlation ID: {tenantContext.CorrelationId.ToString()}")) {
- // Write to the normal log
- LoggerObject.LogDebug(message);
+ // Compatibility overloads below remain available for existing callers and can be removed only in a breaking release.
+ [Obsolete("Use LogCritical(String messageTemplate, params Object?[] args) or the exception-aware structured overload.")] // NOSONAR - retained for source compatibility until a breaking release.
+ public static void LogCritical(Exception exception)
+ {
+ Logger.ValidateLoggerObject();
+ Logger.Write(logger => logger.LogCritical(exception));
+ }
- if (tenantContext.PerTenantLogsEnabled && tenantContext.EstateId != Guid.Empty) {
- // Write to the tenant log
- using (ScopeContext.PushProperty(TenantIdPropertyName, $"_{tenantContext.EstateId.ToString()}")) {
- LoggerObject.LogDebug(message);
- }
- }
- }
+ [Obsolete("Use LogCritical(String messageTemplate, params Object?[] args) or the exception-aware structured overload.")] // NOSONAR - retained for source compatibility until a breaking release.
+ public static void LogCritical(String message,
+ Exception exception)
+ {
+ Logger.ValidateLoggerObject();
+ Logger.Write(logger => logger.LogCritical(message, exception));
}
- public static void LogError(Exception exception) {
- ValidateLoggerObject();
+ [Obsolete("Use LogDebug(String messageTemplate, params Object?[] args).")] // NOSONAR - retained for source compatibility until a breaking release.
+ public static void LogDebug(String message)
+ {
+ Logger.ValidateLoggerObject();
+ Logger.Write(logger => logger.LogDebug(message));
+ }
- TenantContext tenantContext = TenantContext.CurrentTenant;
+ [Obsolete("Use LogError(Exception exception, String messageTemplate, params Object?[] args).")] // NOSONAR - retained for source compatibility until a breaking release.
+ public static void LogError(Exception exception)
+ {
+ Logger.ValidateLoggerObject();
+ Logger.Write(logger => logger.LogError(exception));
+ }
- if (tenantContext == null) {
- LoggerObject.LogError(exception);
- return;
- }
+ [Obsolete("Use LogError(String messageTemplate, params Object?[] args).")] // NOSONAR - retained for source compatibility until a breaking release.
+ public static void LogError(String message)
+ {
+ Logger.ValidateLoggerObject();
+ String sanitizedMessage = Logger.SanitizeForPlainTextLog(message);
+ Logger.Write(logger => logger.LogError(sanitizedMessage));
+ }
- using (ScopeContext.PushProperty(CorrelationIdPropertyName, $"Correlation ID: {tenantContext.CorrelationId.ToString()}")) {
- // Write to the normal log
- LoggerObject.LogError(exception);
+ [Obsolete("Use LogError(Exception exception, String messageTemplate, params Object?[] args).")] // NOSONAR - retained for source compatibility until a breaking release.
+ public static void LogError(String message,
+ Exception exception)
+ {
+ Logger.ValidateLoggerObject();
+ String sanitizedMessage = Logger.SanitizeForPlainTextLog(message);
+ Logger.Write(logger => logger.LogError(sanitizedMessage, exception));
+ }
- if (tenantContext.PerTenantLogsEnabled && tenantContext.EstateId != Guid.Empty) {
- // Write to the tenant log
- using (ScopeContext.PushProperty(TenantIdPropertyName, $"_{tenantContext.EstateId.ToString()}")) {
- LoggerObject.LogError(exception);
- }
- }
- }
+ [Obsolete("Use LogInformation(String messageTemplate, params Object?[] args).")] // NOSONAR - retained for source compatibility until a breaking release.
+ public static void LogInformation(String message)
+ {
+ Logger.ValidateLoggerObject();
+ String sanitizedMessage = Logger.SanitizeForPlainTextLog(message);
+ Logger.Write(logger => logger.LogInformation(sanitizedMessage));
}
- public static void LogError(String message) {
- ValidateLoggerObject();
+ [Obsolete("Use LogTrace(String messageTemplate, params Object?[] args).")] // NOSONAR - retained for source compatibility until a breaking release.
+ public static void LogTrace(String message)
+ {
+ Logger.ValidateLoggerObject();
+ String sanitizedMessage = Logger.SanitizeForPlainTextLog(message);
+ Logger.Write(logger => logger.LogTrace(sanitizedMessage));
+ }
- TenantContext tenantContext = TenantContext.CurrentTenant;
- if (tenantContext == null) {
- LoggerObject.LogError(message);
- return;
- }
- using (ScopeContext.PushProperty(CorrelationIdPropertyName, $"Correlation ID: {tenantContext.CorrelationId.ToString()}")) {
- // Write to the normal log
- LoggerObject.LogError(message);
-
- if (tenantContext.PerTenantLogsEnabled && tenantContext.EstateId != Guid.Empty) {
- // Write to the tenant log
- using (ScopeContext.PushProperty(TenantIdPropertyName, $"_{tenantContext.EstateId.ToString()}")) {
- LoggerObject.LogError(message);
- }
- }
- }
+ [Obsolete("Use LogWarning(String messageTemplate, params Object?[] args).")] // NOSONAR - retained for source compatibility until a breaking release.
+ public static void LogWarning(String message)
+ {
+ Logger.ValidateLoggerObject();
+ String sanitizedMessage = Logger.SanitizeForPlainTextLog(message);
+ Logger.Write(logger => logger.LogWarning(sanitizedMessage));
}
- public static void LogError(String message,
- Exception exception) {
- ValidateLoggerObject();
+ private static String SanitizeForPlainTextLog(String value)
+ {
+ if (String.IsNullOrEmpty(value)) return value;
- TenantContext tenantContext = TenantContext.CurrentTenant;
- if (tenantContext == null) {
- LoggerObject.LogError(message, exception);
- return;
- }
- using (ScopeContext.PushProperty(CorrelationIdPropertyName, $"Correlation ID: {tenantContext.CorrelationId.ToString()}")) {
- // Write to the normal log
- LoggerObject.LogError(message, exception);
-
- if (tenantContext.PerTenantLogsEnabled && tenantContext.EstateId != Guid.Empty) {
- // Write to the tenant log
- using (ScopeContext.PushProperty(TenantIdPropertyName, $"_{tenantContext.EstateId.ToString()}")) {
- LoggerObject.LogError(message, exception);
- }
- }
- }
+ Char[] sanitized = value.Where(character =>
+ // Remove all control characters to prevent log forging and parser confusion.
+ // Keep standard printable text only.
+ !Char.IsControl(character)
+ ).ToArray();
+
+ return new String(sanitized);
}
- public static void LogInformation(String message) {
- ValidateLoggerObject();
+ private static Object?[] SanitizeLogArgs(Object?[] args) =>
+ args.Select(Logger.SanitizeLogValue).ToArray();
- TenantContext tenantContext = TenantContext.CurrentTenant;
- if (tenantContext == null) {
- Logger.LoggerObject.LogInformation(message);
- return;
- }
- using (ScopeContext.PushProperty(CorrelationIdPropertyName, $"Correlation ID: {tenantContext.CorrelationId.ToString()}")) {
- // Write to the normal log
- Logger.LoggerObject.LogInformation(message);
-
- if (tenantContext.PerTenantLogsEnabled && tenantContext.EstateId != Guid.Empty) {
- // Write to the tenant log
- using (ScopeContext.PushProperty(TenantIdPropertyName, $"_{tenantContext.EstateId.ToString()}")) {
- Logger.LoggerObject.LogInformation(message);
- }
+ private static Object? SanitizeLogValue(Object? value)
+ {
+ if (value == null) return null;
+
+ if (value is String text) return Logger.SanitizeForLog(text);
+
+ if (value is IDictionary dictionary)
+ {
+ Dictionary sanitized = new();
+ foreach (DictionaryEntry entry in dictionary)
+ {
+ String key = Logger.SanitizeForLog(Convert.ToString(entry.Key) ?? String.Empty);
+ sanitized[key] = Logger.SanitizeLogValue(entry.Value);
}
+
+ String json = JsonSerializer.Serialize(sanitized);
+ return Logger.SanitizeForLog(json);
+ }
+
+ if (value is IEnumerable enumerable && value is not String)
+ {
+ Object?[] sanitizedItems = enumerable.Cast