diff --git a/src/Cli.Tests/CustomLoggerTests.cs b/src/Cli.Tests/CustomLoggerTests.cs index cce73f0f75..7d66839e58 100644 --- a/src/Cli.Tests/CustomLoggerTests.cs +++ b/src/Cli.Tests/CustomLoggerTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.RegularExpressions; + namespace Cli.Tests; /// @@ -77,8 +79,9 @@ public void LogOutput_UsesAbbreviatedLogLevelLabels(LogLevel logLevel, string ex string actual = expectStderr ? stderr : stdout; string other = expectStderr ? stdout : stderr; - Assert.IsTrue(actual.StartsWith(expectedPrefix), - $"Expected output to start with '{expectedPrefix}' but got: '{actual}'"); + Assert.IsTrue( + Regex.IsMatch(actual, $@"^\d{{4}}-\d{{2}}-\d{{2}}T\d{{2}}:\d{{2}}:\d{{2}}\.\d{{3}}Z {Regex.Escape(expectedPrefix)}"), + $"Expected output to start with an ISO 8601 UTC timestamp followed by '{expectedPrefix}' but got: '{actual}'"); StringAssert.Contains(actual, Message); Assert.AreEqual(string.Empty, other, $"Did not expect output on the other stream but got: '{other}'"); diff --git a/src/Cli/CustomLoggerProvider.cs b/src/Cli/CustomLoggerProvider.cs index a4625b0924..532cd74f2c 100644 --- a/src/Cli/CustomLoggerProvider.cs +++ b/src/Cli/CustomLoggerProvider.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Globalization; using Microsoft.Extensions.Logging; /// @@ -25,6 +26,8 @@ public ILogger CreateLogger(string categoryName) public class CustomConsoleLogger : ILogger { + private const string UTC_TIMESTAMP_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'"; + private readonly LogLevel _minimumLogLevel; // Minimum LogLevel for CLI output. @@ -124,13 +127,14 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except // Apply colors so the abbreviation matches the visual style of engine logs. // try/finally guarantees the original colors are restored even if Write throws, // otherwise the console would be left tinted (e.g. red on error) for subsequent output. + string mcpTimestamp = DateTime.UtcNow.ToString(UTC_TIMESTAMP_FORMAT, CultureInfo.InvariantCulture); ConsoleColor mcpOriginalForeGroundColor = Console.ForegroundColor; ConsoleColor mcpOriginalBackGroundColor = Console.BackgroundColor; try { Console.ForegroundColor = _logLevelToForeGroundConsoleColorMap.GetValueOrDefault(logLevel, ConsoleColor.White); Console.BackgroundColor = _logLevelToBackGroundConsoleColorMap.GetValueOrDefault(logLevel, ConsoleColor.Black); - Console.Error.Write($"{mcpAbbreviation}:"); + Console.Error.Write($"{mcpTimestamp} {mcpAbbreviation}:"); } finally { @@ -153,6 +157,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except } TextWriter writer = logLevel >= LogLevel.Error ? Console.Error : Console.Out; + string timestamp = DateTime.UtcNow.ToString(UTC_TIMESTAMP_FORMAT, CultureInfo.InvariantCulture); // try/finally guarantees the original colors are restored even if Write throws, // otherwise the console would be left tinted (e.g. red on error) for subsequent output. ConsoleColor originalForeGroundColor = Console.ForegroundColor; @@ -161,7 +166,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except { Console.ForegroundColor = _logLevelToForeGroundConsoleColorMap.GetValueOrDefault(logLevel, ConsoleColor.White); Console.BackgroundColor = _logLevelToBackGroundConsoleColorMap.GetValueOrDefault(logLevel, ConsoleColor.Black); - writer.Write($"{abbreviation}:"); + writer.Write($"{timestamp} {abbreviation}:"); } finally { diff --git a/src/Service/Program.cs b/src/Service/Program.cs index 76af52ba97..ca7186698d 100644 --- a/src/Service/Program.cs +++ b/src/Service/Program.cs @@ -26,6 +26,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.ApplicationInsights; +using Microsoft.Extensions.Logging.Console; using OpenTelemetry.Exporter; using OpenTelemetry.Logs; using OpenTelemetry.Resources; @@ -194,6 +195,11 @@ public static IHostBuilder CreateHostBuilder(string[] args, bool runMcpStdio, st else { logging.SetMinimumLevel(LogLevelProvider.CurrentLogLevel); + logging.Services.Configure(options => + { + options.TimestampFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z' "; + options.UseUtcTimestamp = true; + }); } // Add filter for dynamic log level changes (e.g., via MCP logging/setLevel) @@ -464,7 +470,14 @@ public static ILoggerFactory GetLoggerFactoryForLogLevel( // When LogLevel.None, skip the console logger entirely for true silence. if (LogLevelProvider.CurrentLogLevel != LogLevel.None) { - builder.AddConsole(options => + builder.AddSimpleConsole(options => + { + options.TimestampFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z' "; + options.UseUtcTimestamp = true; + }); + // Route all levels to stderr to keep stdout clean for MCP JSON-RPC. + // Uses Services.Configure (not AddConsole) so no second provider is registered. + builder.Services.Configure(options => { options.LogToStandardErrorThreshold = LogLevel.Trace; }); @@ -472,7 +485,11 @@ public static ILoggerFactory GetLoggerFactoryForLogLevel( } else { - builder.AddConsole(); + builder.AddSimpleConsole(options => + { + options.TimestampFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z' "; + options.UseUtcTimestamp = true; + }); } }); }