diff --git a/CHANGELOG.md b/CHANGELOG.md index abbad1f..a35929b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to this repository will be documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [Unreleased] +### Added +- Logging demonstration in both example apps, showing `DGates.AwsSecretsManager`'s `ILogger` + integration works with arbitrary logging frameworks: + - ConsoleExample — custom synchronous `ILoggerProvider`/`ILogger` (`Logging/SyncConsoleLoggerProvider.cs`), + writing directly to `Console.Out` to keep log output correctly interleaved with the demo's + step-by-step narration (`Microsoft.Extensions.Logging.Console`'s async queue caused + out-of-order output when mixed with synchronous console writes) + - MvcExample — NLog, bridged into the library via `NLogLoggerProvider` in + `SecretsManagerAccessor.Initialize()`, with `WeatherController` and `Global.asax.cs` also + converted to NLog directly (`LogManager.GetCurrentClassLogger()`), replacing + `System.Diagnostics.Trace` — one unified logging pipeline for both app and library output + - MvcExample logs to `App_Data/logs/mvcexample.log` and the VS debugger output via + `nlog.config`, `minlevel="Debug"` by default + +### Changed +- ConsoleExample's demo sequence restructured to insert a fetch between `InvalidateCache` and + `RefreshSecretAsync`, so each step's log output proves its narration (invalidate actually + clearing the cache, refresh bypassing a warm cache) rather than asserting it +- Removed ConsoleExample's hardcoded `"[cache hit]"` console message, now redundant with the + library's real `Debug`-level cache-hit log +- `DGates.AwsSecretsManager` dependency bumped to `1.0.0-beta.3` +--- ## [0.2.0] - 2026-07-09 ### Added - MvcExample (ASP.NET MVC 5, .NET Framework 4.8) — demo app retrieving an OpenWeatherMap API diff --git a/src/ConsoleExample/ConsoleExample.csproj b/src/ConsoleExample/ConsoleExample.csproj index ab6870d..9cd59a8 100644 --- a/src/ConsoleExample/ConsoleExample.csproj +++ b/src/ConsoleExample/ConsoleExample.csproj @@ -8,10 +8,6 @@ disable - - - - @@ -25,4 +21,9 @@ + + + + + diff --git a/src/ConsoleExample/Logging/SyncConsoleLoggerProvider.cs b/src/ConsoleExample/Logging/SyncConsoleLoggerProvider.cs new file mode 100644 index 0000000..8b488bc --- /dev/null +++ b/src/ConsoleExample/Logging/SyncConsoleLoggerProvider.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace ConsoleExample.Logging +{ + /// + /// Provides instances that write log entries to the console + /// synchronously on the calling thread, rather than through the built-in console logger's + /// background queue. Guarantees log output is flushed before a short-lived console app like + /// ConsoleExample exits, at the cost of blocking the caller on each write. + /// + public class SyncConsoleLoggerProvider : ILoggerProvider + { + /// Creates a logger for the given category name. + public ILogger CreateLogger(string categoryName) => + new SyncConsoleLogger(categoryName); + + /// No unmanaged resources to release; present only to satisfy . + public void Dispose() { } + + /// + /// Writes each log entry directly to when it's + /// logged, instead of queuing it for a background writer. + /// + private sealed class SyncConsoleLogger : ILogger + { + + private readonly string _categoryName; + + public SyncConsoleLogger(string categoryName) + { + _categoryName = categoryName; + } + + /// Always enabled — this demo logger does not filter by level. + public bool IsEnabled(LogLevel logLevel) => true; + + /// Scopes aren't supported; always returns null. + public IDisposable BeginScope(TState state) => null; + + /// + /// Formats the log entry as [Level] Category: Message (plus the exception + /// details on a following line, if one was supplied) and writes it to the console + /// immediately, blocking the calling thread until the write completes. + /// + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + if (!IsEnabled(logLevel)) return; + + var message = formatter(state, exception); + var logEntry = $"[{logLevel}] {_categoryName}: {message}"; + if (exception != null) + { + logEntry += Environment.NewLine + exception; + } + + Console.WriteLine(logEntry); + } + + } + } +} \ No newline at end of file diff --git a/src/ConsoleExample/Program.cs b/src/ConsoleExample/Program.cs index ca2cde2..32daf41 100644 --- a/src/ConsoleExample/Program.cs +++ b/src/ConsoleExample/Program.cs @@ -1,8 +1,10 @@ using System; using System.Configuration; using System.Threading.Tasks; +using ConsoleExample.Logging; using ConsoleExample.Models; using DGates.AwsSecretsManager; +using Microsoft.Extensions.Logging; namespace ConsoleExample { @@ -10,44 +12,56 @@ internal static class Program { private static async Task Main() { - var settings = new SecretsManagerSettings + using (var loggerFactory = + LoggerFactory.Create(builder => + builder.SetMinimumLevel(LogLevel.Debug) + .AddProvider(new SyncConsoleLoggerProvider()))) { - ServiceUrl = ConfigurationManager.AppSettings["AWSServiceURL"], - Region = ConfigurationManager.AppSettings["AWSRegion"], - LocalJsonFallbackPath = ConfigurationManager.AppSettings["LocalJsonFallbackPath"] - }; - - var secretName = ConfigurationManager.AppSettings["Secrets:DbConfigName"]; - var service = SecretsManagerServiceFactory.Create(settings); - - // 1. Initial fetch — result is cached after this call - Console.WriteLine("Fetching secret (initial)..."); - var config = await service.GetSecretAsync(secretName); - PrintConfig(config); - - // 2. Same secret as raw JSON string - Console.WriteLine("\nFetching secret as raw string..."); - var raw = await service.GetSecretStringAsync(secretName); - Console.WriteLine($" Raw: {raw}"); - - // 3. Evict from cache - Console.WriteLine("\nInvalidating cache..."); - service.InvalidateCache(secretName); - Console.WriteLine(" Cache invalidated."); - - // 4. Force fresh fetch from Secrets Manager, repopulates cache - Console.WriteLine("\nRefreshing secret..."); - config = await service.RefreshSecretAsync(secretName); - PrintConfig(config); - - // 5. Fetch again — served from cache repopulated by RefreshSecretAsync - Console.WriteLine("\nFetching secret (cache hit)..."); - config = await service.GetSecretAsync(secretName); - Console.WriteLine("[cache hit] Secret served from in-memory cache."); - PrintConfig(config); - - Console.WriteLine("\nDone. Press any key to exit."); - Console.ReadKey(); + var logger = loggerFactory.CreateLogger(); + + var settings = new SecretsManagerSettings + { + ServiceUrl = ConfigurationManager.AppSettings["AWSServiceURL"], + Region = ConfigurationManager.AppSettings["AWSRegion"], + LocalJsonFallbackPath = ConfigurationManager.AppSettings["LocalJsonFallbackPath"] + }; + + var secretName = ConfigurationManager.AppSettings["Secrets:DbConfigName"]; + var service = SecretsManagerServiceFactory.Create(settings, logger); + + // 1. Initial fetch — result is cached after this call + Console.WriteLine("Fetching secret (initial)..."); + var config = await service.GetSecretAsync(secretName); + PrintConfig(config); + + // 2. Same secret as raw JSON string + Console.WriteLine("\nFetching secret as raw string..."); + var raw = await service.GetSecretStringAsync(secretName); + Console.WriteLine($" Raw: {raw}"); + + // 3. Evict from cache + Console.WriteLine("\nInvalidating cache..."); + service.InvalidateCache(secretName); + Console.WriteLine(" Cache invalidated."); + + // 4. Fetch - this will be a cache miss and will fetch from Secrets Manager again + Console.WriteLine("\nFetching secret (after cache invalidation)..."); + config = await service.GetSecretAsync(secretName); + PrintConfig(config); + + // 5. Force fresh fetch from Secrets Manager, repopulates cache + Console.WriteLine("\nRefreshing secret..."); + config = await service.RefreshSecretAsync(secretName); + PrintConfig(config); + + // 6. Fetch again — served from cache repopulated by RefreshSecretAsync + Console.WriteLine("\nFetching secret (cache hit)..."); + config = await service.GetSecretAsync(secretName); + PrintConfig(config); + + Console.WriteLine("\nDone. Press any key to exit."); + Console.ReadKey(); + } } private static void PrintConfig(DbConfig config) diff --git a/src/MvcExample.Infrastructure/MvcExample.Infrastructure.csproj b/src/MvcExample.Infrastructure/MvcExample.Infrastructure.csproj index 87f36ea..0ff8586 100644 --- a/src/MvcExample.Infrastructure/MvcExample.Infrastructure.csproj +++ b/src/MvcExample.Infrastructure/MvcExample.Infrastructure.csproj @@ -8,12 +8,13 @@ - + + - - + + diff --git a/src/MvcExample.Infrastructure/SecretsManagerAccessor.cs b/src/MvcExample.Infrastructure/SecretsManagerAccessor.cs index e3abbe9..510565c 100644 --- a/src/MvcExample.Infrastructure/SecretsManagerAccessor.cs +++ b/src/MvcExample.Infrastructure/SecretsManagerAccessor.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using System.Web.Hosting; using DGates.AwsSecretsManager; +using NLog.Extensions.Logging; namespace MvcExample.Infrastructure { @@ -15,6 +16,8 @@ public static class SecretsManagerAccessor { private static ISecretsManagerService _instance; private static string _source; + + private static readonly NLog.Logger Log = NLog.LogManager.GetCurrentClassLogger(); public static void Initialize() { @@ -36,8 +39,17 @@ public static void Initialize() : !string.IsNullOrWhiteSpace(serviceUrl) ? "LocalStack (via ServiceUrl override)" : "AWS Secrets Manager"; + + Log.Info("SecretsManagerAccessor initializing, backend source: {Source}", _source); + if (!string.IsNullOrWhiteSpace(localJsonFallbackPath)) + { + Log.Info("Resolved LocalJsonFallbackPath: {ResolvedPath}", localJsonFallbackPath); + } - _instance = SecretsManagerServiceFactory.Create(settings); + var loggerProvider = new NLogLoggerProvider(); + var logger = loggerProvider.CreateLogger(nameof(SecretsManagerService)); + + _instance = SecretsManagerServiceFactory.Create(settings, logger); } public static async Task> GetSecretAsync(string secretName) where T : class diff --git a/src/MvcExample/Controllers/WeatherController.cs b/src/MvcExample/Controllers/WeatherController.cs index 2b14d31..4dc139e 100644 --- a/src/MvcExample/Controllers/WeatherController.cs +++ b/src/MvcExample/Controllers/WeatherController.cs @@ -1,12 +1,12 @@ using System; using System.Configuration; -using System.Diagnostics; using System.IO; using System.Net.Http; using System.Threading.Tasks; using System.Web.Mvc; using MvcExample.Core; using MvcExample.Infrastructure; +using NLog; namespace MvcExample.Controllers { @@ -14,6 +14,8 @@ public class WeatherController : Controller { private static readonly HttpClient HttpClient = new HttpClient(); + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); + [HttpGet] public async Task Index() { @@ -24,12 +26,12 @@ public async Task Index() } catch (FileNotFoundException ex) { - Trace.TraceError("LocalJsonFallbackPath file not found on page load: " + ex); + Log.Error(ex, "LocalJsonFallbackPath file not found on page load"); return View(WeatherViewModelBuilder.LocalFallbackFileNotFound()); } catch (Exception ex) { - Trace.TraceError("Failed to retrieve OpenWeatherMap secret on page load: " + ex); + Log.Error(ex, "Failed to retrieve OpenWeatherMap secret on page load"); return View(WeatherViewModelBuilder.BackendUnavailable()); } @@ -44,6 +46,8 @@ public async Task Index() [HttpPost] public async Task Index(string cityName) { + Log.Info("Received request for weather data for city: {CityName}", cityName); + if (string.IsNullOrWhiteSpace(cityName)) { return View(WeatherViewModelBuilder.MissingCityName()); @@ -56,12 +60,12 @@ public async Task Index(string cityName) } catch (FileNotFoundException ex) { - Trace.TraceError("LocalJsonFallbackPath file not found: " + ex); + Log.Error(ex, "LocalJsonFallbackPath file not found"); return View(WeatherViewModelBuilder.LocalFallbackFileNotFound()); } catch (Exception ex) { - Trace.TraceError("Failed to retrieve OpenWeatherMap secret: " + ex); + Log.Error(ex, "Failed to retrieve OpenWeatherMap secret"); return View(WeatherViewModelBuilder.Error(cityName)); } @@ -92,7 +96,7 @@ public async Task Index(string cityName) } catch (Exception ex) { - Trace.TraceError("Failed to retrieve weather data for '" + cityName + "': " + ex); + Log.Error(ex, "Failed to retrieve weather data for '{CityName}'", cityName); return View(WeatherViewModelBuilder.Error(cityName)); } } diff --git a/src/MvcExample/Global.asax.cs b/src/MvcExample/Global.asax.cs index 9b7591c..fc3d426 100644 --- a/src/MvcExample/Global.asax.cs +++ b/src/MvcExample/Global.asax.cs @@ -1,17 +1,19 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Web; using System.Web.Mvc; using System.Web.Optimization; using System.Web.Routing; using MvcExample.Infrastructure; +using NLog; namespace MvcExample { public class MvcApplication : System.Web.HttpApplication { + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); + protected void Application_Start() { try @@ -24,7 +26,7 @@ protected void Application_Start() // requests that actually need the secrets service will fail individually // (SecretsManagerAccessor throws a clear "not initialized" error) instead of // every request 500ing until this is fixed and the app pool recycled. - Trace.TraceError("Failed to initialize SecretsManagerAccessor: " + ex); + Log.Error(ex, "Failed to initialize SecretsManagerAccessor"); } AreaRegistration.RegisterAllAreas(); diff --git a/src/MvcExample/MvcExample.csproj b/src/MvcExample/MvcExample.csproj index bb8acff..dbaaca8 100644 --- a/src/MvcExample/MvcExample.csproj +++ b/src/MvcExample/MvcExample.csproj @@ -68,6 +68,8 @@ + + @@ -130,6 +132,12 @@ $(PkgMicrosoft_CodeDom_Providers_DotNetCompilerPlatform)\lib\net45\Microsoft.CodeDom.Providers.DotNetCompilerPlatform.dll + + $(PkgNLog)\lib\net46\NLog.dll + + + $(PkgNLog_Web)\lib\net46\NLog.Web.dll + @@ -184,6 +192,9 @@ + + Always + diff --git a/src/MvcExample/nlog.config b/src/MvcExample/nlog.config new file mode 100644 index 0000000..d6bc197 --- /dev/null +++ b/src/MvcExample/nlog.config @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file