From 862ac8e1f209561614fb6eab9caa4bd82c7714cc Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Thu, 16 Oct 2025 16:28:00 +0100 Subject: [PATCH] Add structured logging with mcpd-compatible log format --- README.md | 29 +++++++++-- src/MozillaAI.Mcpd.Plugins.Sdk/BasePlugin.cs | 5 +- .../McpdLogFormatter.cs | 48 +++++++++++++++++ .../PluginServer.cs | 52 +++++++++++-------- 4 files changed, 107 insertions(+), 27 deletions(-) create mode 100644 src/MozillaAI.Mcpd.Plugins.Sdk/McpdLogFormatter.cs diff --git a/README.md b/README.md index 6f09524..39e12a1 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,15 @@ public override Task HandleRequest(HTTPRequest request, Grpc.Core. ### Logging -Plugins that extend `BasePlugin` have access to an `ILogger` instance via the `Logger` property. This allows plugins to emit structured logs that appear in the host application's output. +Plugins that extend `BasePlugin` have access to an `ILogger` instance via the `Logger` property. The SDK automatically provides a default logger that outputs logs in a format compatible with mcpd's log level inference: + +``` +[INFO] Plugin server listening on unix /var/... +[WARN] Rate limit exceeded +[ERROR] Failed to process request +``` + +This format allows mcpd to properly categorize log messages by severity. ```csharp using Microsoft.Extensions.Logging; @@ -142,13 +150,13 @@ public class MyPlugin : BasePlugin { public override Task Configure(PluginConfig request, Grpc.Core.ServerCallContext context) { - Logger?.LogInformation("Plugin configured with {Count} settings", request.Settings.Count); + Logger.LogInformation("Plugin configured with {Count} settings", request.Settings.Count); return Task.FromResult(new Empty()); } public override Task HandleRequest(HTTPRequest request, Grpc.Core.ServerCallContext context) { - Logger?.LogInformation("Processing request: {Method} {Path}", request.Method, request.Path); + Logger.LogInformation("Processing request: {Method} {Path}", request.Method, request.Path); // Your plugin logic here. @@ -157,7 +165,20 @@ public class MyPlugin : BasePlugin } ``` -The SDK automatically suppresses ASP.NET Core framework logs at the Info level to reduce noise. Plugin logs at Info level and above will appear normally in the host application's output. +For advanced scenarios, you can provide your own logger: + +```csharp +var loggerFactory = LoggerFactory.Create(builder => +{ + builder.AddJsonConsole(); + builder.SetMinimumLevel(LogLevel.Debug); +}); +var logger = loggerFactory.CreateLogger(); + +return await PluginServer.Serve(args, logger); +``` + +The SDK automatically suppresses ASP.NET Core framework logs at the Info level to reduce noise. ## Running Your Plugin diff --git a/src/MozillaAI.Mcpd.Plugins.Sdk/BasePlugin.cs b/src/MozillaAI.Mcpd.Plugins.Sdk/BasePlugin.cs index 4423522..e1e108d 100644 --- a/src/MozillaAI.Mcpd.Plugins.Sdk/BasePlugin.cs +++ b/src/MozillaAI.Mcpd.Plugins.Sdk/BasePlugin.cs @@ -1,6 +1,7 @@ using Google.Protobuf.WellKnownTypes; using Grpc.Core; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace MozillaAI.Mcpd.Plugins.V1; @@ -46,9 +47,9 @@ namespace MozillaAI.Mcpd.Plugins.V1; public class BasePlugin : Plugin.PluginBase { /// - /// Logger instance for the plugin. Available after SetLogger is called by the SDK. + /// Logger instance for the plugin. /// - protected ILogger? Logger { get; private set; } + protected ILogger Logger { get; private set; } = NullLogger.Instance; /// /// SetLogger is called by the SDK to provide a logger to the plugin. diff --git a/src/MozillaAI.Mcpd.Plugins.Sdk/McpdLogFormatter.cs b/src/MozillaAI.Mcpd.Plugins.Sdk/McpdLogFormatter.cs new file mode 100644 index 0000000..4cd916b --- /dev/null +++ b/src/MozillaAI.Mcpd.Plugins.Sdk/McpdLogFormatter.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Console; + +namespace MozillaAI.Mcpd.Plugins.V1; + +/// +/// Console formatter that outputs log messages in a format compatible with mcpd's log inference. +/// +internal sealed class McpdLogFormatter : ConsoleFormatter +{ + public McpdLogFormatter() : base("mcpd") + { + } + + public override void Write(in LogEntry logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter) + { + var message = logEntry.Formatter(logEntry.State, logEntry.Exception); + if (string.IsNullOrEmpty(message)) + { + return; + } + + var levelString = GetLevelString(logEntry.LogLevel); + textWriter.Write(levelString); + textWriter.Write(' '); + textWriter.WriteLine(message); + + if (logEntry.Exception != null) + { + textWriter.WriteLine(logEntry.Exception.ToString()); + } + } + + private static string GetLevelString(LogLevel logLevel) + { + return logLevel switch + { + LogLevel.Trace => "[TRACE]", + LogLevel.Debug => "[DEBUG]", + LogLevel.Information => "[INFO]", + LogLevel.Warning => "[WARN]", + LogLevel.Error => "[ERROR]", + LogLevel.Critical => "[ERROR]", + _ => "[INFO]" + }; + } +} diff --git a/src/MozillaAI.Mcpd.Plugins.Sdk/PluginServer.cs b/src/MozillaAI.Mcpd.Plugins.Sdk/PluginServer.cs index ffd1595..1b68267 100644 --- a/src/MozillaAI.Mcpd.Plugins.Sdk/PluginServer.cs +++ b/src/MozillaAI.Mcpd.Plugins.Sdk/PluginServer.cs @@ -4,8 +4,8 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; namespace MozillaAI.Mcpd.Plugins.V1; @@ -31,10 +31,11 @@ public static class PluginServer /// /// The plugin implementation type that inherits from Plugin.PluginBase. /// Command-line arguments. + /// Optional logger instance. If null, a default console logger is created. /// Exit code (0 for success, 1 for error). - public static async Task Serve(string[] args) where T : Plugin.PluginBase, new() + public static async Task Serve(string[] args, ILogger? logger = null) where T : Plugin.PluginBase, new() { - return await Serve(new T(), args); + return await Serve(new T(), args, logger); } /// @@ -42,9 +43,26 @@ public static class PluginServer /// /// The plugin implementation instance. /// Command-line arguments. + /// Optional logger instance. If null, a default console logger is created. /// Exit code (0 for success, 1 for error). - public static async Task Serve(Plugin.PluginBase implementation, string[] args) + public static async Task Serve(Plugin.PluginBase implementation, string[] args, ILogger? logger = null) { + // Create default console logger if none provided. + logger ??= LoggerFactory.Create(builder => + { + builder.AddConsole(options => options.FormatterName = "mcpd"); + builder.AddConsoleFormatter(); + builder.AddFilter("Microsoft.AspNetCore", LogLevel.Warning); + builder.AddFilter("Microsoft.Extensions", LogLevel.Warning); + builder.AddFilter("Microsoft.Hosting", LogLevel.Warning); + }).CreateLogger(implementation.GetType()); + + // Provide logger to BasePlugin implementations. + if (implementation is BasePlugin basePlugin) + { + basePlugin.SetLogger(logger); + } + var addressOption = new Option( name: "--address", description: "gRPC address (socket path for unix, host:port for tcp)") @@ -63,27 +81,25 @@ public static async Task Serve(Plugin.PluginBase implementation, string[] a networkOption }; - rootCommand.SetHandler(async (address, network) => - { - await RunServer(implementation, address, network); - }, addressOption, networkOption); + rootCommand.SetHandler(async (address, network) => await RunServer(implementation, address, network, logger), addressOption, networkOption); return await rootCommand.InvokeAsync(args); } - private static async Task RunServer(Plugin.PluginBase implementation, string address, string network) + private static async Task RunServer(Plugin.PluginBase implementation, string address, string network, ILogger logger) { var builder = WebApplication.CreateSlimBuilder(); - // Suppress ASP.NET Core diagnostic logs but allow plugin logs. + // Suppress ASP.NET Core diagnostic logs. builder.Logging.ClearProviders(); - builder.Logging.AddConsole(); + builder.Logging.AddConsole(options => options.FormatterName = "mcpd"); + builder.Logging.AddConsoleFormatter(); builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning); builder.Logging.AddFilter("Microsoft.Extensions", LogLevel.Warning); builder.Logging.AddFilter("Microsoft.Hosting", LogLevel.Warning); // Configure Kestrel. - builder.WebHost.ConfigureKestrel((context, serverOptions) => + builder.WebHost.ConfigureKestrel((_, serverOptions) => { if (network.Equals("unix", StringComparison.OrdinalIgnoreCase)) { @@ -129,7 +145,8 @@ private static async Task RunServer(Plugin.PluginBase implementation, string add // Map gRPC service. app.MapGrpcService(); - Console.WriteLine($"Plugin server listening on {network} {address}"); + logger.LogInformation("Plugin server listening on {Network} {Address}", network, address); + try { @@ -152,16 +169,9 @@ private class PluginServiceAdapter : Plugin.PluginBase { private readonly Plugin.PluginBase _implementation; - public PluginServiceAdapter(Plugin.PluginBase implementation, ILoggerFactory loggerFactory) + public PluginServiceAdapter(Plugin.PluginBase implementation) { _implementation = implementation; - - // Provide logger to BasePlugin implementations. - if (_implementation is BasePlugin basePlugin) - { - var logger = loggerFactory.CreateLogger(_implementation.GetType()); - basePlugin.SetLogger(logger); - } } public override Task Configure(PluginConfig request, Grpc.Core.ServerCallContext context)