diff --git a/playground/Stress/Stress.ApiService/ConsoleStresser.cs b/playground/Stress/Stress.ApiService/ConsoleStresser.cs index 0a45e9166df..03ab3e8a487 100644 --- a/playground/Stress/Stress.ApiService/ConsoleStresser.cs +++ b/playground/Stress/Stress.ApiService/ConsoleStresser.cs @@ -97,8 +97,8 @@ public static void Stress() for (var color = 40; color <= 47; color++) { Console.Write("\x1b[" + color + "m"); // Set background color - Console.WriteLine($"This is background color {color}"); - Console.Write("\x1b[0m"); // Reset colors to default after each background to maintain readability + Console.Write($"This is background color {color}"); + Console.WriteLine("\x1b[0m"); // Reset colors to default after each background to maintain readability } Console.Write("\x1b[0m"); // Reset all colors to default at the end diff --git a/playground/Stress/Stress.ApiService/Program.cs b/playground/Stress/Stress.ApiService/Program.cs index 5025e67b2ed..12321930113 100644 --- a/playground/Stress/Stress.ApiService/Program.cs +++ b/playground/Stress/Stress.ApiService/Program.cs @@ -22,6 +22,23 @@ app.MapGet("/", () => "Hello world"); +app.MapGet("/write-console", () => +{ + for (var i = 0; i < 5000; i++) + { + if (i % 500 == 0) + { + Console.Error.WriteLine($"{i} Error"); + } + else + { + Console.Out.WriteLine($"{i} Out"); + } + } + + return "Console written"; +}); + app.MapGet("/increment-counter", (TestMetrics metrics) => { metrics.IncrementCounter(1, new TagList([new KeyValuePair("add-tag", "1")])); diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index 5360e709f4c..09d1833850f 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -229,5 +229,8 @@ + + + diff --git a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor index b2759e63925..80c0f1bffdb 100644 --- a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor +++ b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor @@ -1,5 +1,6 @@ @namespace Aspire.Dashboard.Components @using Aspire.Dashboard.Model +@using Aspire.Hosting.ConsoleLogs @inject IJSRuntime JS @implements IAsyncDisposable diff --git a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs index 5b3c3a1206c..89cfd5a6c3c 100644 --- a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs @@ -8,6 +8,7 @@ using Aspire.Dashboard.Extensions; using Aspire.Dashboard.Model; using Aspire.Dashboard.Utils; +using Aspire.Hosting.ConsoleLogs; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Options; using Microsoft.JSInterop; @@ -32,7 +33,7 @@ public sealed partial class LogViewer [Inject] public required IOptions Options { get; set; } - public LogEntries LogEntries { get; set; } = null!; + internal LogEntries LogEntries { get; set; } = null!; public string? ResourceName { get; set; } @@ -85,7 +86,14 @@ internal async Task SetLogSourceAsync(string resourceName, IAsyncEnumerable _logEntries = new(maximumEntryCount); - - private int? _baseLineNumber; - - public void Clear() - { - _logEntries.Clear(); - - _baseLineNumber = null; - } - - public IList GetEntries() => _logEntries; - - public void InsertSorted(LogEntry logEntry, int lineNumber) - { - // Keep track of the base line number to ensure that we can calculate the line number of each log entry. - // This becomes important when the total number of log entries exceeds the limit and is truncated. - _baseLineNumber ??= lineNumber; - - if (logEntry.ParentId != null) - { - // If we have a parent id, then we know we're on a non-timestamped line that is part - // of a multi-line log entry. We need to find the prior line from that entry - for (var rowIndex = _logEntries.Count - 1; rowIndex >= 0; rowIndex--) - { - var current = _logEntries[rowIndex]; - - if (current.Id == logEntry.ParentId && logEntry.LineIndex - 1 == current.LineIndex) - { - InsertAt(rowIndex + 1); - return; - } - } - } - else if (logEntry.Timestamp != null) - { - // Otherwise, if we have a timestamped line, we just need to find the prior line. - // Since the rows are always in order, as soon as we see a timestamp - // that is less than the one we're adding, we can insert it immediately after that - for (var rowIndex = _logEntries.Count - 1; rowIndex >= 0; rowIndex--) - { - var current = _logEntries[rowIndex]; - var currentTimestamp = current.Timestamp ?? current.ParentTimestamp; - - if (currentTimestamp != null && currentTimestamp <= logEntry.Timestamp) - { - InsertAt(rowIndex + 1); - return; - } - } - } - - // If we didn't find a place to insert then append it to the end. This happens with the first entry, but - // could also happen if the logs don't have recognized timestamps. - InsertAt(_logEntries.Count); - - void InsertAt(int index) - { - // Set the line number of the log entry. - if (index == 0) - { - Debug.Assert(_baseLineNumber != null, "Should be set before this method is run."); - logEntry.LineNumber = _baseLineNumber.Value; - } - else - { - logEntry.LineNumber = _logEntries[index - 1].LineNumber + 1; - } - - // Insert the entry. - _logEntries.Insert(index, logEntry); - - // If a log entry isn't inserted at the end then update the line numbers of all subsequent entries. - for (var i = index + 1; i < _logEntries.Count; i++) - { - _logEntries[i].LineNumber++; - } - } - } -} diff --git a/src/Aspire.Dashboard/ConsoleLogs/LogEntry.cs b/src/Aspire.Dashboard/ConsoleLogs/LogEntry.cs deleted file mode 100644 index 7f2a3f373ec..00000000000 --- a/src/Aspire.Dashboard/ConsoleLogs/LogEntry.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics; - -namespace Aspire.Dashboard.Model; - -[DebuggerDisplay("Timestamp = {(Timestamp ?? ParentTimestamp),nq}, Content = {Content}")] -public sealed class LogEntry -{ - public string? Content { get; set; } - public DateTimeOffset? Timestamp { get; set; } - public LogEntryType Type { get; init; } = LogEntryType.Default; - public int LineIndex { get; set; } - public Guid? ParentId { get; set; } - public Guid Id { get; } = Guid.NewGuid(); - public DateTimeOffset? ParentTimestamp { get; set; } - public bool IsFirstLine { get; init; } - public int LineNumber { get; set; } -} - -public enum LogEntryType -{ - Default, - Error -} diff --git a/src/Aspire.Dashboard/ConsoleLogs/LogParser.cs b/src/Aspire.Dashboard/ConsoleLogs/LogParser.cs index be07607b95e..0c40faf4bb1 100644 --- a/src/Aspire.Dashboard/ConsoleLogs/LogParser.cs +++ b/src/Aspire.Dashboard/ConsoleLogs/LogParser.cs @@ -2,15 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; -using Aspire.Dashboard.Model; +using Aspire.Hosting.ConsoleLogs; namespace Aspire.Dashboard.ConsoleLogs; internal sealed class LogParser { - private DateTimeOffset? _parentTimestamp; - private Guid? _parentId; - private int _lineIndex; private AnsiParser.ParserState? _residualState; public LogEntry CreateLogEntry(string rawText, bool isErrorOutput) @@ -18,74 +15,44 @@ public LogEntry CreateLogEntry(string rawText, bool isErrorOutput) // Several steps to do here: // // 1. Parse the content to look for the timestamp - // 2. Parse the content to look for info/warn/dbug header - // 3. HTML Encode the raw text for security purposes - // 4. Parse the content to look for ANSI Control Sequences and color them if possible - // 5. Parse the content to look for URLs and make them links if possible - // 6. Create the LogEntry to get the ID - // 7. Set the relative properties of the log entry (parent/line index/etc) - // 8. Return the final result + // 2. HTML Encode the raw text for security purposes + // 3. Parse the content to look for ANSI Control Sequences and color them if possible + // 4. Parse the content to look for URLs and make them links if possible + // 5. Create the LogEntry var content = rawText; // 1. Parse the content to look for the timestamp - var isFirstLine = false; - DateTimeOffset? timestamp = null; + DateTime? timestamp = null; if (TimestampParser.TryParseConsoleTimestamp(content, out var timestampParseResult)) { - isFirstLine = true; content = timestampParseResult.Value.ModifiedText; - timestamp = timestampParseResult.Value.Timestamp; - } - // 2. Parse the content to look for info/warn/dbug header - // TODO extract log level and use here - else - { - if (LogLevelParser.StartsWithLogLevelHeader(content)) - { - isFirstLine = true; - } + timestamp = timestampParseResult.Value.Timestamp.UtcDateTime; } - // 3. HTML Encode the raw text for security purposes + // 2. HTML Encode the raw text for security purposes content = WebUtility.HtmlEncode(content); - // 4. Parse the content to look for ANSI Control Sequences and color them if possible + // 3. Parse the content to look for ANSI Control Sequences and color them if possible var conversionResult = AnsiParser.ConvertToHtml(content, _residualState); content = conversionResult.ConvertedText; _residualState = conversionResult.ResidualState; - // 5. Parse the content to look for URLs and make them links if possible + // 4. Parse the content to look for URLs and make them links if possible if (UrlParser.TryParse(content, out var modifiedText)) { content = modifiedText; } - // 6. Create the LogEntry to get the ID + // 5. Create the LogEntry var logEntry = new LogEntry { Timestamp = timestamp, Content = content, - Type = isErrorOutput ? LogEntryType.Error : LogEntryType.Default, - IsFirstLine = isFirstLine + Type = isErrorOutput ? LogEntryType.Error : LogEntryType.Default }; - // 7. Set the relative properties of the log entry (parent/line index/etc) - if (isFirstLine) - { - _parentTimestamp = logEntry.Timestamp; - _parentId = logEntry.Id; - _lineIndex = 0; - } - else if (_parentId.HasValue) - { - logEntry.ParentTimestamp = _parentTimestamp; - logEntry.ParentId = _parentId; - logEntry.LineIndex = ++_lineIndex; - } - - // 8. Return the final result return logEntry; } } diff --git a/src/Aspire.Dashboard/ConsoleLogs/TimestampParser.cs b/src/Aspire.Dashboard/ConsoleLogs/TimestampParser.cs deleted file mode 100644 index 9b5d67ae31b..00000000000 --- a/src/Aspire.Dashboard/ConsoleLogs/TimestampParser.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Text.RegularExpressions; -using Aspire.Dashboard.Extensions; -using Aspire.Dashboard.Model; - -namespace Aspire.Dashboard.ConsoleLogs; - -public static partial class TimestampParser -{ - private static readonly Regex s_rfc3339RegEx = GenerateRfc3339RegEx(); - - public static bool TryParseConsoleTimestamp(string text, [NotNullWhen(true)] out TimestampParserResult? result) - { - var match = s_rfc3339RegEx.Match(text); - - if (match.Success) - { - var span = text.AsSpan(); - var timestamp = span[match.Index..(match.Index + match.Length)]; - var theRest = match.Index + match.Length >= span.Length ? "" : span[(match.Index + match.Length)..]; - - result = new(theRest.ToString(), DateTimeOffset.Parse(timestamp.ToString(), CultureInfo.InvariantCulture)); - return true; - } - - result = default; - return false; - } - - public static string ConvertTimestampFromUtc(BrowserTimeProvider timeProvider, ReadOnlySpan timestamp) - { - if (DateTimeOffset.TryParse(timestamp, out var dateTimeUtc)) - { - var dateTimeLocal = timeProvider.ToLocal(dateTimeUtc); - return dateTimeLocal.ToString(KnownFormats.ConsoleLogsTimestampFormat, CultureInfo.CurrentCulture); - } - - return timestamp.ToString(); - } - - // Regular Expression for an RFC3339 timestamp, including RFC3339Nano - // - // Example timestamps: - // 2023-10-02T12:56:35.123456789Z - // 2023-10-02T13:56:35.123456789+10:00 - // 2023-10-02T13:56:35.123456789-10:00 - // 2023-10-02T13:56:35.123456789Z10:00 - // 2023-10-02T13:56:35.123456Z - // 2023-10-02T13:56:35Z - // - // Explanation: - // ^ - Starts the string - // (?:\\d{4}) - Four digits for the year - // - - Separator for the date - // (?:0[1-9]|1[0-2]) - Two digits for the month, restricted to 01-12 - // - - Separator for the date - // (?:0[1-9]|[12][0-9]|3[01]) - Two digits for the day, restricted to 01-31 - // [T ] - Literal, separator between date and time, either a T or a space - // (?:[01][0-9]|2[0-3]) - Two digits for the hour, restricted to 00-23 - // : - Separator for the time - // (?:[0-5][0-9]) - Two digits for the minutes, restricted to 00-59 - // : - Separator for the time - // (?:[0-5][0-9]) - Two digits for the seconds, restricted to 00-59 - // (?:\\.\\d{1,9}) - A period and up to nine digits for the partial seconds - // Z - Literal, same as +00:00 - // (?:[Z+-](?:[01][0-9]|2[0-3]):(?:[0-5][0-9])) - Time Zone offset, in the form ZHH:MM or +HH:MM or -HH:MM - // - // Note: (?:) is a non-capturing group, since we don't care about the values, we are just interested in whether or not there is a match - [GeneratedRegex("^(?:\\d{4})-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])T(?:[01][0-9]|2[0-3]):(?:[0-5][0-9]):(?:[0-5][0-9])(?:\\.\\d{1,9})?(?:Z|(?:[Z+-](?:[01][0-9]|2[0-3]):(?:[0-5][0-9])))?")] - private static partial Regex GenerateRfc3339RegEx(); - - public readonly record struct TimestampParserResult(string ModifiedText, DateTimeOffset Timestamp); -} diff --git a/src/Aspire.Dashboard/ConsoleLogs/UrlParser.cs b/src/Aspire.Dashboard/ConsoleLogs/UrlParser.cs index 631b2d83e61..f139cdafd15 100644 --- a/src/Aspire.Dashboard/ConsoleLogs/UrlParser.cs +++ b/src/Aspire.Dashboard/ConsoleLogs/UrlParser.cs @@ -7,6 +7,7 @@ using System.Text.RegularExpressions; namespace Aspire.Dashboard.ConsoleLogs; + public static partial class UrlParser { private static readonly Regex s_urlRegEx = GenerateUrlRegEx(); diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceLoggerService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceLoggerService.cs index 76778e42118..1ff147f1bf2 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceLoggerService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceLoggerService.cs @@ -6,7 +6,7 @@ using System.Globalization; using System.Runtime.CompilerServices; using System.Threading.Channels; -using Aspire.Dashboard.Otlp.Storage; +using Aspire.Hosting.ConsoleLogs; using Microsoft.Extensions.Logging; namespace Aspire.Hosting.ApplicationModel; @@ -16,8 +16,10 @@ namespace Aspire.Hosting.ApplicationModel; /// public class ResourceLoggerService { - private readonly ConcurrentDictionary _loggers = new(); + // Internal for testing. + internal TimeProvider TimeProvider { get; set; } = TimeProvider.System; + private readonly ConcurrentDictionary _loggers = new(); private Action<(string, ResourceLoggerState)>? _loggerAdded; private event Action<(string, ResourceLoggerState)> LoggerAdded { @@ -64,7 +66,7 @@ public ILogger GetLogger(string resourceName) /// The internal logger is used when adding logs from resource's stream logs. /// It allows the parsed date from text to be used as the log line date. /// - internal Action GetInternalLogger(string resourceName) + internal Action GetInternalLogger(string resourceName) { ArgumentNullException.ThrowIfNull(resourceName); @@ -176,14 +178,12 @@ public void ClearBacklog(string resourceName) internal ResourceLoggerState GetResourceLoggerState(string resourceName) => _loggers.GetOrAdd(resourceName, (name, context) => { - var state = new ResourceLoggerState(); + var state = new ResourceLoggerState(TimeProvider); context._loggerAdded?.Invoke((name, state)); return state; }, this); - internal sealed record InternalLogLine(DateTime DateTimeUtc, string Message, bool IsError); - /// /// A logger for the resource to write to. /// @@ -191,17 +191,18 @@ internal sealed class ResourceLoggerState { private readonly ResourceLogger _logger; private readonly CancellationTokenSource _logStreamCts = new(); + private readonly object _lock = new(); - private Task? _backlogReplayCompleteTask; - private long _lastLogReceivedTimestamp; - private readonly CircularBuffer _backlog = new(10000); + private readonly LogEntries _backlog = new(10000) { BaseLineNumber = 0 }; + private readonly TimeProvider _timeProvider; /// /// Creates a new . /// - public ResourceLoggerState() + public ResourceLoggerState(TimeProvider timeProvider) { _logger = new ResourceLogger(this); + _timeProvider = timeProvider; } private Action? _onSubscribersChanged; @@ -213,7 +214,7 @@ public event Action OnSubscribersChanged var hasSubscribers = false; - lock (this) + lock (_lock) { if (_onNewLog is not null) // we have subscribers { @@ -241,31 +242,19 @@ public async IAsyncEnumerable> WatchAsync([EnumeratorCanc // Line number always restarts from 1 when watching logs. // Note that this will need to be improved if the log source (DCP) is changed to return a maximum number of lines. var lineNumber = 1; - var channel = Channel.CreateUnbounded(); + var channel = Channel.CreateUnbounded(); using var _ = _logStreamCts.Token.Register(() => channel.Writer.TryComplete()); - InternalLogLine[]? backlogSnapshot = null; - void Log(InternalLogLine log) - { - lock (_backlog) - { - // Don't write to the channel until the backlog snapshot is accessed. - // This prevents duplicate logs in result. - if (backlogSnapshot != null) - { - channel.Writer.TryWrite(log); - } - } - } - OnNewLog += Log; + // No need to lock in the log method because TryWrite/TryComplete are already threadsafe. + void Log(LogEntry log) => channel.Writer.TryWrite(log); - // Add a small delay to ensure the backlog is replayed from DCP and ordered correctly. - await EnsureBacklogReplayAsync(cancellationToken).ConfigureAwait(false); - - lock (_backlog) + LogEntry[] backlogSnapshot; + lock (_lock) { + // Get back backlogSnapshot = GetBacklogSnapshot(); + OnNewLog += Log; } if (backlogSnapshot.Length > 0) @@ -282,65 +271,46 @@ void Log(InternalLogLine log) } finally { - OnNewLog -= Log; - - channel.Writer.TryComplete(); - } - - static LogLine[] CreateLogLines(ref int lineNumber, IReadOnlyList entry) - { - var logs = new LogLine[entry.Count]; - for (var i = 0; i < entry.Count; i++) + lock (_lock) { - logs[i] = new LogLine(lineNumber, entry[i].Message, entry[i].IsError); - lineNumber++; + OnNewLog -= Log; + channel.Writer.TryComplete(); } - - return logs; - } - } - - private Task EnsureBacklogReplayAsync(CancellationToken cancellationToken) - { - lock (_backlog) - { - _backlogReplayCompleteTask ??= StartBacklogReplayAsync(cancellationToken); - return _backlogReplayCompleteTask; } - async Task StartBacklogReplayAsync(CancellationToken cancellationToken) + static LogLine[] CreateLogLines(ref int lineNumber, IReadOnlyList entries) { - var delay = TimeSpan.FromMilliseconds(100); - - // There could be an initial burst of logs as they're replayed. Give them the opportunity to be loaded - // into the backlog in the correct order and returned before streaming logs as they arrive. - for (var i = 0; i < 3; i++) + var logs = new LogLine[entries.Count]; + for (var i = 0; i < entries.Count; i++) { - await Task.Delay(delay, cancellationToken).ConfigureAwait(false); - lock (_backlog) + var entry = entries[i]; + var content = entry.Content ?? string.Empty; + if (entry.Timestamp != null) { - if (_lastLogReceivedTimestamp != 0 && Stopwatch.GetElapsedTime(_lastLogReceivedTimestamp) > delay) - { - break; - } + content = entry.Timestamp.Value.ToString(KnownFormats.ConsoleLogsTimestampFormat, CultureInfo.InvariantCulture) + " " + content; } + + logs[i] = new LogLine(lineNumber, content, entry.Type == LogEntryType.Error); + lineNumber++; } + + return logs; } } // This provides the fan out to multiple subscribers. - private Action? _onNewLog; - private event Action OnNewLog + private Action? _onNewLog; + private event Action OnNewLog { add { - bool raiseSubscribersChanged; - lock (this) - { - raiseSubscribersChanged = _onNewLog is null; // is this the first subscriber? + Debug.Assert(Monitor.IsEntered(_lock)); - _onNewLog += value; - } + // When this is the first subscriber, raise event so WatchAnySubscribersAsync publishes an update. + // Is this the first subscriber? + var raiseSubscribersChanged = _onNewLog is null; + + _onNewLog += value; if (raiseSubscribersChanged) { @@ -349,16 +319,20 @@ private event Action OnNewLog } remove { - bool raiseSubscribersChanged; - lock (this) - { - _onNewLog -= value; + Debug.Assert(Monitor.IsEntered(_lock)); - raiseSubscribersChanged = _onNewLog is null; // is this the last subscriber? - } + _onNewLog -= value; + // When there are no more subscribers, raise event so WatchAnySubscribersAsync publishes an update. + // Is this the last subscriber? + var raiseSubscribersChanged = _onNewLog is null; if (raiseSubscribersChanged) { + // Clear backlog immediately. + // Avoids a race between message being subscription changed notification eventually clearing the + // logs and someone else watching logs and getting the backlog + complete replay off all logs. + ClearBacklog(); + _onSubscribersChanged?.Invoke(false); } } @@ -380,47 +354,30 @@ public void Complete() public void ClearBacklog() { - lock (_backlog) + lock (_lock) { _backlog.Clear(); - _backlogReplayCompleteTask = null; + _backlog.BaseLineNumber = 0; } } - internal InternalLogLine[] GetBacklogSnapshot() + internal LogEntry[] GetBacklogSnapshot() { - lock (_backlog) + lock (_lock) { - return [.. _backlog]; + return [.. _backlog.GetEntries()]; } } - public void AddLog(DateTime dateTimeUtc, string logMessage, bool isErrorMessage) + public void AddLog(DateTime? timestamp, string logMessage, bool isErrorMessage) { - InternalLogLine logLine; - lock (_backlog) + var logEntry = new LogEntry { Timestamp = timestamp, Content = logMessage, Type = isErrorMessage ? LogEntryType.Error : LogEntryType.Default }; + lock (_lock) { - logLine = new InternalLogLine(dateTimeUtc, logMessage, isErrorMessage); - - var added = false; - for (var i = _backlog.Count - 1; i >= 0; i--) - { - if (dateTimeUtc >= _backlog[i].DateTimeUtc) - { - _backlog.Insert(i + 1, logLine); - added = true; - break; - } - } - if (!added) - { - _backlog.Insert(0, logLine); - } - - _lastLogReceivedTimestamp = Stopwatch.GetTimestamp(); + _backlog.InsertSorted(logEntry); } - _onNewLog?.Invoke(logLine); + _onNewLog?.Invoke(logEntry); } private sealed class ResourceLogger(ResourceLoggerState loggerState) : ILogger @@ -437,43 +394,15 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except return; } + var logTime = loggerState._timeProvider.GetUtcNow().UtcDateTime; + var logMessage = formatter(state, exception) + (exception is null ? "" : $"\n{exception}"); var isErrorMessage = logLevel >= LogLevel.Error; - loggerState.AddLog(DateTime.UtcNow, logMessage, isErrorMessage); + loggerState.AddLog(logTime, logMessage, isErrorMessage); } } } - - internal static bool TryParseContentLineDate(string content, out DateTime value) - { - const int MinDateLength = 20; // Date + time without fractional seconds. - const int MaxDateLength = 30; // Date + time with fractional seconds. - - if (content.Length >= MinDateLength) - { - var firstSpaceIndex = content.IndexOf(' ', StringComparison.Ordinal); - if (firstSpaceIndex > 0) - { - if (DateTimeOffset.TryParse(content.AsSpan(0, firstSpaceIndex), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime)) - { - value = dateTime.UtcDateTime; - return true; - } - } - else if (content.Length <= MaxDateLength) - { - if (DateTimeOffset.TryParse(content, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime)) - { - value = dateTime.UtcDateTime; - return true; - } - } - } - - value = default; - return false; - } } /// diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index db204a745a2..74711a62aeb 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -37,6 +37,8 @@ + + @@ -68,6 +70,7 @@ + diff --git a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs index 5fcc04fcea7..667ba03b1d0 100644 --- a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs +++ b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs @@ -8,6 +8,7 @@ using System.Net.Sockets; using System.Text.Json; using System.Threading.Channels; +using Aspire.Dashboard.ConsoleLogs; using Aspire.Dashboard.Model; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dashboard; @@ -286,14 +287,6 @@ await Task.WhenAll( { logStream.Cancellation.Cancel(); } - - if (_containersMap.TryGetValue(entry.ResourceName, out var _) || - _executablesMap.TryGetValue(entry.ResourceName, out var _)) - { - // Clear out the backlog for containers and executables after the last subscriber leaves. - // When a new subscriber is added, the full log will be replayed. - loggerService.ClearBacklog(entry.ResourceName); - } } } @@ -494,13 +487,16 @@ private void StartLogStream(T resource) where T : CustomResource { foreach (var (content, isError) in batch) { - if (!ResourceLoggerService.TryParseContentLineDate(content, out var dateTimeUtc)) + DateTime? timestamp = null; + var resolvedContent = content; + + if (TimestampParser.TryParseConsoleTimestamp(resolvedContent, out var result)) { - // If a date can't be read from the line content then use the current date. - dateTimeUtc = DateTime.UtcNow; + resolvedContent = result.Value.ModifiedText; + timestamp = result.Value.Timestamp.UtcDateTime; } - logger(dateTimeUtc, content, isError); + logger(timestamp, resolvedContent, isError); } } } diff --git a/src/Shared/ConsoleLogs/LogEntries.cs b/src/Shared/ConsoleLogs/LogEntries.cs new file mode 100644 index 00000000000..aaa6160f330 --- /dev/null +++ b/src/Shared/ConsoleLogs/LogEntries.cs @@ -0,0 +1,151 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Aspire.Dashboard.Otlp.Storage; + +namespace Aspire.Hosting.ConsoleLogs; + +[DebuggerDisplay("Count = {EntriesCount}")] +internal sealed class LogEntries(int maximumEntryCount) +{ + private readonly CircularBuffer _logEntries = new(maximumEntryCount); + + private int? _earliestTimestampIndex; + + // Keep track of the base line number to ensure that we can calculate the line number of each log entry. + // This becomes important when the total number of log entries exceeds the limit and is truncated. + public int? BaseLineNumber { get; set; } + + public IList GetEntries() => _logEntries; + + public int EntriesCount => _logEntries.Count; + + public void Clear() + { + _logEntries.Clear(); + BaseLineNumber = null; + } + + public void InsertSorted(LogEntry logLine) + { + Debug.Assert(logLine.Timestamp == null || logLine.Timestamp.Value.Kind == DateTimeKind.Utc, "Timestamp should always be UTC."); + + InsertSortedCore(logLine); + + // Verify log entry order is correct in debug builds. + VerifyLogEntryOrder(); + } + + [Conditional("DEBUG")] + private void VerifyLogEntryOrder() + { + DateTimeOffset lastTimestamp = default; + for (var i = 0; i < _logEntries.Count; i++) + { + var entry = _logEntries[i]; + if (entry.Timestamp is { } timestamp) + { + if (timestamp < lastTimestamp) + { + throw new InvalidOperationException("Log entries out of order."); + } + else + { + lastTimestamp = timestamp; + } + } + } + } + + private void InsertSortedCore(LogEntry logEntry) + { + // If there is no timestamp then add to the end. + if (logEntry.Timestamp == null) + { + InsertAt(_logEntries.Count); + return; + } + + int? missingTimestampIndex = null; + for (var rowIndex = _logEntries.Count - 1; rowIndex >= 0; rowIndex--) + { + var current = _logEntries[rowIndex]; + + // If the current entry has no timestamp then we can't match against it. + // Keep a track of the first entry with no timestamp, as we want to insert + // ahead of it if we can't find a more exact place to insert the entry. + if (current.Timestamp == null) + { + if (missingTimestampIndex == null) + { + missingTimestampIndex = rowIndex; + } + + continue; + } + + // Add log entry if it is later than current entry. + if (logEntry.Timestamp.Value >= current.Timestamp.Value) + { + // If there were lines with no timestamp before current entry + // then insert after the lines with no timestamp. + if (missingTimestampIndex != null) + { + InsertAt(missingTimestampIndex.Value + 1); + return; + } + + InsertAt(rowIndex + 1); + return; + } + else + { + missingTimestampIndex = null; + } + } + + if (_earliestTimestampIndex != null) + { + InsertAt(_earliestTimestampIndex.Value); + } + else if (missingTimestampIndex != null) + { + InsertAt(missingTimestampIndex.Value + 1); + } + else + { + // New log entry timestamp is smaller than existing entries timestamps. + // Or maybe there just aren't any other entries yet. + InsertAt(0); + } + + void InsertAt(int index) + { + // Set the line number of the log entry. + if (index == 0) + { + Debug.Assert(BaseLineNumber != null, "Should be set before this method is run."); + logEntry.LineNumber = BaseLineNumber.Value; + } + else + { + logEntry.LineNumber = _logEntries[index - 1].LineNumber + 1; + } + + if (_earliestTimestampIndex == null && logEntry.Timestamp != null) + { + _earliestTimestampIndex = index; + } + + // Insert the entry. + _logEntries.Insert(index, logEntry); + + // If a log entry isn't inserted at the end then update the line numbers of all subsequent entries. + for (var i = index + 1; i < _logEntries.Count; i++) + { + _logEntries[i].LineNumber++; + } + } + } +} diff --git a/src/Shared/ConsoleLogs/LogEntry.cs b/src/Shared/ConsoleLogs/LogEntry.cs new file mode 100644 index 00000000000..bfa599af111 --- /dev/null +++ b/src/Shared/ConsoleLogs/LogEntry.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Aspire.Hosting.ConsoleLogs; + +[DebuggerDisplay("LineNumber = {LineNumber}, Timestamp = {Timestamp}, Content = {Content}, Type = {Type}")] +internal sealed class LogEntry +{ + public string? Content { get; set; } + public DateTime? Timestamp { get; set; } + public LogEntryType Type { get; init; } = LogEntryType.Default; + public int LineNumber { get; set; } +} + +internal enum LogEntryType +{ + Default, + Error +} diff --git a/src/Shared/ConsoleLogs/TimestampParser.cs b/src/Shared/ConsoleLogs/TimestampParser.cs index 36cc412dbb4..55a25520521 100644 --- a/src/Shared/ConsoleLogs/TimestampParser.cs +++ b/src/Shared/ConsoleLogs/TimestampParser.cs @@ -9,16 +9,14 @@ namespace Aspire.Dashboard.ConsoleLogs; internal static partial class TimestampParser { - private static readonly Regex s_rfc3339RegEx = GenerateRfc3339RegEx(); - public static bool TryParseConsoleTimestamp(string text, [NotNullWhen(true)] out TimestampParserResult? result) { - var match = s_rfc3339RegEx.Match(text); + // Regex is cached inside the method. + var match = GenerateRfc3339RegEx().Match(text); if (match.Success) { var span = text.AsSpan(); - var timestamp = span[match.Index..(match.Index + match.Length)]; ReadOnlySpan content; if (match.Index + match.Length >= span.Length) @@ -36,7 +34,7 @@ public static bool TryParseConsoleTimestamp(string text, [NotNullWhen(true)] out } } - result = new(content.ToString(), DateTimeOffset.Parse(timestamp.ToString(), CultureInfo.InvariantCulture)); + result = new(content.ToString(), DateTimeOffset.Parse(match.ValueSpan, CultureInfo.InvariantCulture)); return true; } @@ -53,26 +51,23 @@ public static bool TryParseConsoleTimestamp(string text, [NotNullWhen(true)] out // 2023-10-02T13:56:35.123456789Z10:00 // 2023-10-02T13:56:35.123456Z // 2023-10-02T13:56:35Z - // - // Explanation: - // ^ - Starts the string - // (?:\\d{4}) - Four digits for the year - // - - Separator for the date - // (?:0[1-9]|1[0-2]) - Two digits for the month, restricted to 01-12 - // - - Separator for the date - // (?:0[1-9]|[12][0-9]|3[01]) - Two digits for the day, restricted to 01-31 - // [T ] - Literal, separator between date and time, either a T or a space - // (?:[01][0-9]|2[0-3]) - Two digits for the hour, restricted to 00-23 - // : - Separator for the time - // (?:[0-5][0-9]) - Two digits for the minutes, restricted to 00-59 - // : - Separator for the time - // (?:[0-5][0-9]) - Two digits for the seconds, restricted to 00-59 - // (?:\\.\\d{1,9}) - A period and up to nine digits for the partial seconds - // Z - Literal, same as +00:00 - // (?:[Z+-](?:[01][0-9]|2[0-3]):(?:[0-5][0-9])) - Time Zone offset, in the form ZHH:MM or +HH:MM or -HH:MM - // - // Note: (?:) is a non-capturing group, since we don't care about the values, we are just interested in whether or not there is a match - [GeneratedRegex("^(?:\\d{4})-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])T(?:[01][0-9]|2[0-3]):(?:[0-5][0-9]):(?:[0-5][0-9])(?:\\.\\d{1,9})?(?:Z|(?:[Z+-](?:[01][0-9]|2[0-3]):(?:[0-5][0-9])))?")] + [GeneratedRegex(""" + ^ # Starts the string + (\d{4}) # Four digits for the year + - # Separator for the date + (0[1-9]|1[0-2]) # Two digits for the month, restricted to 01-12 + - # Separator for the date + (0[1-9]|[12][0-9]|3[01]) # Two digits for the day, restricted to 01-31 + T # Literal, separator between date and time, either a T or a space + ([01][0-9]|2[0-3]) # Two digits for the hour, restricted to 00-23 + : # Separator for the time + ([0-5][0-9]) # Two digits for the minutes, restricted to 00-59 + : # Separator for the time + ([0-5][0-9]) # Two digits for the seconds, restricted to 00-59 + (\.\d{1,9})? # A period and up to nine digits for the partial seconds (optional) + (Z|([Z+-]([01][0-9]|2[0-3]):([0-5][0-9])))? # Time Zone offset, in the form Z or ZHH:MM or +HH:MM or -HH:MM (optional) + """, + RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant | RegexOptions.IgnorePatternWhitespace)] private static partial Regex GenerateRfc3339RegEx(); public readonly record struct TimestampParserResult(string ModifiedText, DateTimeOffset Timestamp); diff --git a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogEntriesTests.cs b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogEntriesTests.cs index 68e67aee2e4..bbd2765b74e 100644 --- a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogEntriesTests.cs +++ b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogEntriesTests.cs @@ -2,25 +2,189 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.ConsoleLogs; -using Aspire.Dashboard.Model; +using Aspire.Hosting.ConsoleLogs; using Xunit; namespace Aspire.Dashboard.Tests.ConsoleLogsTests; public class LogEntriesTests { + private static LogEntries CreateLogEntries(int? maximumEntryCount = null, int? baseLineNumber = null) + { + var logEntries = new LogEntries(maximumEntryCount: maximumEntryCount ?? int.MaxValue); + logEntries.BaseLineNumber = baseLineNumber ?? 1; + return logEntries; + } + + private static void AddLogLine(LogEntries logEntries, string content, bool isError) + { + var logParser = new LogParser(); + var logEntry = logParser.CreateLogEntry(content, isError); + logEntries.InsertSorted(logEntry); + } + + [Fact] + public void AddLogLine_Single() + { + // Arrange + var logEntries = CreateLogEntries(); + + // Act + AddLogLine(logEntries, "Hello world", isError: false); + + // Assert + var entry = Assert.Single(logEntries.GetEntries()); + Assert.Equal("Hello world", entry.Content); + Assert.Null(entry.Timestamp); + } + + [Fact] + public void AddLogLine_MultipleLines() + { + // Arrange + var logEntries = CreateLogEntries(); + + // Act + AddLogLine(logEntries, "Hello world", isError: false); + AddLogLine(logEntries, "Hello world 2", isError: false); + AddLogLine(logEntries, "Hello world 3", isError: true); + + // Assert + Assert.Collection(logEntries.GetEntries(), + l => Assert.Equal("Hello world", l.Content), + l => Assert.Equal("Hello world 2", l.Content), + l => Assert.Equal("Hello world 3", l.Content)); + } + + [Fact] + public void AddLogLine_MultipleLines_MixDatePrefix() + { + // Arrange + var logEntries = CreateLogEntries(); + + // Act + AddLogLine(logEntries, "Hello world", isError: false); + AddLogLine(logEntries, "2024-08-19T06:10:01.000Z Hello world 2", isError: false); + AddLogLine(logEntries, "2024-08-19T06:10:02.000Z Hello world 3", isError: false); + AddLogLine(logEntries, "Hello world 4", isError: false); + AddLogLine(logEntries, "2024-08-19T06:10:03.000Z Hello world 5", isError: false); + + // Assert + var entries = logEntries.GetEntries(); + Assert.Collection(entries, + l => + { + Assert.Equal("Hello world", l.Content); + Assert.Equal(1, l.LineNumber); + }, + l => + { + Assert.Equal("Hello world 2", l.Content); + Assert.Equal(2, l.LineNumber); + }, + l => + { + Assert.Equal("Hello world 3", l.Content); + Assert.Equal(3, l.LineNumber); + }, + l => + { + Assert.Equal("Hello world 4", l.Content); + Assert.Equal(4, l.LineNumber); + }, + l => + { + Assert.Equal("Hello world 5", l.Content); + Assert.Equal(5, l.LineNumber); + }); + } + + [Fact] + public void AddLogLine_MultipleLines_MixDatePrefix_OutOfOrder() + { + // Arrange + var logEntries = CreateLogEntries(); + + // Act + AddLogLine(logEntries, "Hello world", isError: false); + AddLogLine(logEntries, "2024-08-19T06:12:00.000Z Hello world 2", isError: false); + AddLogLine(logEntries, "2024-08-19T06:11:00.000Z Hello world 3", isError: false); + AddLogLine(logEntries, "Hello world 4", isError: false); + AddLogLine(logEntries, "2024-08-19T06:13:00.000Z Hello world 5", isError: false); + AddLogLine(logEntries, "2024-08-19T06:10:00.000Z Hello world 6", isError: false); + + // Assert + var entries = logEntries.GetEntries(); + Assert.Collection(entries, + l => + { + Assert.Equal("Hello world", l.Content); + Assert.Equal(1, l.LineNumber); + }, + l => + { + Assert.Equal("Hello world 6", l.Content); + Assert.Equal(2, l.LineNumber); + }, + l => + { + Assert.Equal("Hello world 3", l.Content); + Assert.Equal(3, l.LineNumber); + }, + l => + { + Assert.Equal("Hello world 2", l.Content); + Assert.Equal(4, l.LineNumber); + }, + l => + { + Assert.Equal("Hello world 4", l.Content); + Assert.Equal(5, l.LineNumber); + }, + l => + { + Assert.Equal("Hello world 5", l.Content); + Assert.Equal(6, l.LineNumber); + }); + } + + [Fact] + public void AddLogLine_MultipleLines_SameDate_InOrder() + { + // Arrange + var logEntries = CreateLogEntries(); + + // Act + AddLogLine(logEntries, "2024-08-19T06:10:00.000Z Hello world 1", isError: false); + AddLogLine(logEntries, "2024-08-19T06:10:00.000Z Hello world 2", isError: false); + + // Assert + var entries = logEntries.GetEntries(); + Assert.Collection(entries, + l => + { + Assert.Equal("Hello world 1", l.Content); + Assert.Equal(1, l.LineNumber); + }, + l => + { + Assert.Equal("Hello world 2", l.Content); + Assert.Equal(2, l.LineNumber); + }); + } + [Fact] public void InsertSorted_OutOfOrderWithSameTimestamp_ReturnInOrder() { // Arrange - var logEntries = new LogEntries(maximumEntryCount: int.MaxValue); + var logEntries = CreateLogEntries(); - var timestamp = DateTimeOffset.UtcNow; + var timestamp = DateTime.UtcNow; // Act - logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(1), Content = "1" }, 1); - logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(3), Content = "3" }, 3); - logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(2), Content = "2" }, 2); + logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(1), Content = "1" }); + logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(3), Content = "3" }); + logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(2), Content = "2" }); // Assert var entries = logEntries.GetEntries(); @@ -34,14 +198,14 @@ public void InsertSorted_OutOfOrderWithSameTimestamp_ReturnInOrder() public void InsertSorted_TrimsToMaximumEntryCount_Ordered() { // Arrange - var logEntries = new LogEntries(maximumEntryCount: 2); + var logEntries = CreateLogEntries(maximumEntryCount: 2); - var timestamp = DateTimeOffset.UtcNow; + var timestamp = DateTime.UtcNow; // Act - logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(1), Content = "1" }, 1); - logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(2), Content = "2" }, 2); - logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(3), Content = "3" }, 3); + logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(1), Content = "1" }); + logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(2), Content = "2" }); + logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(3), Content = "3" }); // Assert var entries = logEntries.GetEntries(); @@ -54,14 +218,14 @@ public void InsertSorted_TrimsToMaximumEntryCount_Ordered() public void InsertSorted_TrimsToMaximumEntryCount_OutOfOrder() { // Arrange - var logEntries = new LogEntries(maximumEntryCount: 2); + var logEntries = CreateLogEntries(maximumEntryCount: 2); - var timestamp = DateTimeOffset.UtcNow; + var timestamp = DateTime.UtcNow; // Act - logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(1), Content = "1" }, 1); - logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(3), Content = "3" }, 2); - logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(2), Content = "2" }, 3); + logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(1), Content = "1" }); + logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(3), Content = "3" }); + logEntries.InsertSorted(new LogEntry { Timestamp = timestamp.AddSeconds(2), Content = "2" }); // Assert var entries = logEntries.GetEntries(); diff --git a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/TimestampParserTests.cs b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/TimestampParserTests.cs index 0712e0b907a..fb4c1427c98 100644 --- a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/TimestampParserTests.cs +++ b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/TimestampParserTests.cs @@ -22,8 +22,8 @@ public void TryColorizeTimestamp_DoesNotStartWithTimestamp_ReturnsFalse(string i [Theory] [InlineData("2023-10-10T15:05:30.123456789Z", true, "", "2023-10-10T15:05:30.123456789Z")] - [InlineData("2023-10-10T15:05:30.123456789Z ", true, " ", "2023-10-10T15:05:30.123456789Z")] - [InlineData("2023-10-10T15:05:30.123456789Z with some text after it", true, " with some text after it", "2023-10-10T15:05:30.123456789Z")] + [InlineData("2023-10-10T15:05:30.123456789Z ", true, "", "2023-10-10T15:05:30.123456789Z")] + [InlineData("2023-10-10T15:05:30.123456789Z with some text after it", true, "with some text after it", "2023-10-10T15:05:30.123456789Z")] [InlineData("With some text before it 2023-10-10T15:05:30.123456789Z", false, null, null)] public void TryColorizeTimestamp_ReturnsCorrectResult(string input, bool expectedResult, string? expectedOutput, string? expectedTimestamp) { diff --git a/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj b/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj index 7d54aeadc16..0b757fade93 100644 --- a/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj +++ b/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj @@ -17,9 +17,10 @@ - - + + + diff --git a/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs b/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs index 52a17ce00a9..94a6939adde 100644 --- a/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Hosting.Tests.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting.Internal; @@ -54,7 +55,7 @@ public async Task ResourceLogsAreForwardedToHostLogging() { var hostApplicationLifetime = new TestHostApplicationLifetime(); var resourceNotificationService = new ResourceNotificationService(NullLogger.Instance, hostApplicationLifetime); - var resourceLoggerService = new ResourceLoggerService(); + var resourceLoggerService = ConsoleLoggingTestHelpers.GetResourceLoggerService(); var hostEnvironment = new HostingEnvironment { ApplicationName = "TestApp.AppHost" }; var fakeLoggerProvider = new FakeLoggerProvider(); var fakeLoggerFactory = new LoggerFactory([fakeLoggerProvider, new XunitLoggerProvider(output)]); @@ -118,12 +119,12 @@ public async Task ResourceLogsAreForwardedToHostLogging() // Category is derived from the application name and resource name // Logs sent at information level or lower are logged as information, otherwise they are logged as error Assert.Collection(hostLogs, - log => { Assert.Equal(LogLevel.Information, log.Level); Assert.Equal("1: Test trace message", log.Message); Assert.Equal("TestApp.AppHost.Resources.myresource", log.Category); }, - log => { Assert.Equal(LogLevel.Information, log.Level); Assert.Equal("2: Test debug message", log.Message); Assert.Equal("TestApp.AppHost.Resources.myresource", log.Category); }, - log => { Assert.Equal(LogLevel.Information, log.Level); Assert.Equal("3: Test information message", log.Message); Assert.Equal("TestApp.AppHost.Resources.myresource", log.Category); }, - log => { Assert.Equal(LogLevel.Information, log.Level); Assert.Equal("4: Test warning message", log.Message); Assert.Equal("TestApp.AppHost.Resources.myresource", log.Category); }, - log => { Assert.Equal(LogLevel.Error, log.Level); Assert.Equal("5: Test error message", log.Message); Assert.Equal("TestApp.AppHost.Resources.myresource", log.Category); }, - log => { Assert.Equal(LogLevel.Error, log.Level); Assert.Equal("6: Test critical message", log.Message); Assert.Equal("TestApp.AppHost.Resources.myresource", log.Category); }); + log => { Assert.Equal(LogLevel.Information, log.Level); Assert.Equal("1: 2000-12-29T20:59:59.0000000 Test trace message", log.Message); Assert.Equal("TestApp.AppHost.Resources.myresource", log.Category); }, + log => { Assert.Equal(LogLevel.Information, log.Level); Assert.Equal("2: 2000-12-29T20:59:59.0000000 Test debug message", log.Message); Assert.Equal("TestApp.AppHost.Resources.myresource", log.Category); }, + log => { Assert.Equal(LogLevel.Information, log.Level); Assert.Equal("3: 2000-12-29T20:59:59.0000000 Test information message", log.Message); Assert.Equal("TestApp.AppHost.Resources.myresource", log.Category); }, + log => { Assert.Equal(LogLevel.Information, log.Level); Assert.Equal("4: 2000-12-29T20:59:59.0000000 Test warning message", log.Message); Assert.Equal("TestApp.AppHost.Resources.myresource", log.Category); }, + log => { Assert.Equal(LogLevel.Error, log.Level); Assert.Equal("5: 2000-12-29T20:59:59.0000000 Test error message", log.Message); Assert.Equal("TestApp.AppHost.Resources.myresource", log.Category); }, + log => { Assert.Equal(LogLevel.Error, log.Level); Assert.Equal("6: 2000-12-29T20:59:59.0000000 Test critical message", log.Message); Assert.Equal("TestApp.AppHost.Resources.myresource", log.Category); }); } private sealed class CustomResource(string name) : Resource(name) diff --git a/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj b/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj index b86ad20ceb1..87b60907e6f 100644 --- a/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj +++ b/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj @@ -20,6 +20,7 @@ + diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs index 440e662369d..51aa395ee3c 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs @@ -20,7 +20,7 @@ public class DashboardLifecycleHookTests { [Theory] [MemberData(nameof(Data))] - public async Task WatchDashboardLogs_WrittenToHostLoggerFactory(string logMessage, string expectedMessage, string expectedCategory, LogLevel expectedLevel) + public async Task WatchDashboardLogs_WrittenToHostLoggerFactory(DateTime? timestamp, string logMessage, string expectedMessage, string expectedCategory, LogLevel expectedLevel) { // Arrange var testSink = new TestSink(); @@ -59,8 +59,8 @@ public async Task WatchDashboardLogs_WrittenToHostLoggerFactory(string logMessag } // Act - var dashboardLogger = resourceLoggerService.GetLogger(KnownResourceNames.AspireDashboard); - dashboardLogger.LogError(logMessage); + var dashboardLoggerState = resourceLoggerService.GetResourceLoggerState(KnownResourceNames.AspireDashboard); + dashboardLoggerState.AddLog(timestamp, logMessage, isErrorMessage: false); // Assert var logContext = await logChannel.Reader.ReadAsync(); @@ -69,7 +69,7 @@ public async Task WatchDashboardLogs_WrittenToHostLoggerFactory(string logMessag Assert.Equal(expectedLevel, logContext.LogLevel); } - public static IEnumerable Data() + public static IEnumerable Data() { var timestamp = new DateTime(2001, 12, 29, 23, 59, 59, DateTimeKind.Utc); var message = new DashboardLogMessage @@ -81,22 +81,17 @@ public static IEnumerable Data() }; var messageJson = JsonSerializer.Serialize(message, DashboardLogMessageContext.Default.DashboardLogMessage); - yield return new object[] + yield return new object?[] { - $"{DateTime.UtcNow.ToString(KnownFormats.ConsoleLogsTimestampFormat, CultureInfo.InvariantCulture)} {messageJson}", - "Hello world", - "Aspire.Hosting.Dashboard.TestCategory", - LogLevel.Error - }; - yield return new object[] - { - $"{DateTime.UtcNow.ToString(KnownFormats.ConsoleLogsTimestampFormat, CultureInfo.InvariantCulture)}{messageJson}", + DateTime.UtcNow, + messageJson, "Hello world", "Aspire.Hosting.Dashboard.TestCategory", LogLevel.Error }; - yield return new object[] + yield return new object?[] { + null, messageJson, "Hello world", "Aspire.Hosting.Dashboard.TestCategory", @@ -113,8 +108,9 @@ public static IEnumerable Data() }; messageJson = JsonSerializer.Serialize(message, DashboardLogMessageContext.Default.DashboardLogMessage); - yield return new object[] + yield return new object?[] { + null, messageJson, $"Error message{Environment.NewLine}System.InvalidOperationException: Error!", "Aspire.Hosting.Dashboard.TestCategory.TestSubCategory", diff --git a/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs index 0a53c8748fd..285b804aa9d 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs @@ -424,7 +424,7 @@ public async Task ResourceLogging_MultipleStreams_StreamedOverTime() await pipes.StandardOut.Writer.WriteAsync(Encoding.UTF8.GetBytes("2024-08-19T06:10:33.473275911Z Hello world" + Environment.NewLine)); Assert.True(await moveNextTask); var logLine = watchLogsEnumerator.Current.Single(); - Assert.Equal("2024-08-19T06:10:33.473275911Z Hello world", logLine.Content); + Assert.Equal("2024-08-19T06:10:33.4732759 Hello world", logLine.Content); Assert.Equal(1, logLine.LineNumber); Assert.False(logLine.IsErrorMessage); @@ -435,14 +435,14 @@ public async Task ResourceLogging_MultipleStreams_StreamedOverTime() await pipes.StandardErr.Writer.WriteAsync(Encoding.UTF8.GetBytes("2024-08-19T06:10:32.661Z Next" + Environment.NewLine)); Assert.True(await moveNextTask); logLine = watchLogsEnumerator.Current.Single(); - Assert.Equal("2024-08-19T06:10:32.661Z Next", logLine.Content); + Assert.Equal("2024-08-19T06:10:32.6610000 Next", logLine.Content); Assert.Equal(2, logLine.LineNumber); Assert.True(logLine.IsErrorMessage); var loggerState = resourceLoggerService.GetResourceLoggerState(exeResource.Metadata.Name); Assert.Collection(loggerState.GetBacklogSnapshot(), - l => Assert.Equal("2024-08-19T06:10:32.661Z Next", l.Message), - l => Assert.Equal("2024-08-19T06:10:33.473275911Z Hello world", l.Message)); + l => Assert.Equal("Next", l.Content), + l => Assert.Equal("Hello world", l.Content)); // Stop watching. moveNextTask = watchLogsEnumerator.MoveNextAsync().AsTask(); @@ -504,10 +504,9 @@ public async Task ResourceLogging_ReplayBacklog_SentInBatch() var watchSubscribers = resourceLoggerService.WatchAnySubscribersAsync(); var watchSubscribersEnumerator = watchSubscribers.GetAsyncEnumerator(); var watchLogs1 = resourceLoggerService.WatchAsync(exeResource.Metadata.Name); - var watchLogsEnumerator1 = watchLogs1.GetAsyncEnumerator(watchCts.Token); + var watchLogsTask1 = ConsoleLoggingTestHelpers.WatchForLogsAsync(watchLogs1, targetLogCount: 7); - var moveNextTask = watchLogsEnumerator1.MoveNextAsync().AsTask(); - Assert.False(moveNextTask.IsCompletedSuccessfully, "No logs yet."); + Assert.False(watchLogsTask1.IsCompletedSuccessfully, "Logs not available yet."); await watchSubscribersEnumerator.MoveNextAsync(); Assert.Equal(exeResource.Metadata.Name, watchSubscribersEnumerator.Current.Name); @@ -516,28 +515,27 @@ public async Task ResourceLogging_ReplayBacklog_SentInBatch() exeResource.Status = new ContainerStatus { State = ContainerState.Running }; kubernetesService.PushResourceModified(exeResource); - Assert.True(await moveNextTask); - Assert.Collection(watchLogsEnumerator1.Current, - l => Assert.Equal("2024-08-19T06:10:01.000Z First", l.Content), - l => Assert.Equal("2024-08-19T06:10:02.000Z Second", l.Content), - l => Assert.Equal("2024-08-19T06:10:03.000Z Third", l.Content), - l => Assert.Equal("2024-08-19T06:10:04.000Z Forth", l.Content), - l => Assert.Equal("2024-08-19T06:10:04.000Z Fifth", l.Content), - l => Assert.Equal("2024-08-19T06:10:05.000Z Sixth", l.Content), - l => Assert.Equal("2024-08-19T06:10:05.000Z Seventh", l.Content)); + var watchLogsResults1 = await watchLogsTask1; + Assert.Equal(7, watchLogsResults1.Count); + Assert.Contains(watchLogsResults1, l => l.Content.Contains("First")); + Assert.Contains(watchLogsResults1, l => l.Content.Contains("Second")); + Assert.Contains(watchLogsResults1, l => l.Content.Contains("Third")); + Assert.Contains(watchLogsResults1, l => l.Content.Contains("Forth")); + Assert.Contains(watchLogsResults1, l => l.Content.Contains("Fifth")); + Assert.Contains(watchLogsResults1, l => l.Content.Contains("Sixth")); + Assert.Contains(watchLogsResults1, l => l.Content.Contains("Seventh")); var watchLogs2 = resourceLoggerService.WatchAsync(exeResource.Metadata.Name); - var watchLogsEnumerator2 = watchLogs2.GetAsyncEnumerator(watchCts.Token); - - Assert.True(await watchLogsEnumerator2.MoveNextAsync()); - Assert.Collection(watchLogsEnumerator2.Current, - l => Assert.Equal("2024-08-19T06:10:01.000Z First", l.Content), - l => Assert.Equal("2024-08-19T06:10:02.000Z Second", l.Content), - l => Assert.Equal("2024-08-19T06:10:03.000Z Third", l.Content), - l => Assert.Equal("2024-08-19T06:10:04.000Z Forth", l.Content), - l => Assert.Equal("2024-08-19T06:10:04.000Z Fifth", l.Content), - l => Assert.Equal("2024-08-19T06:10:05.000Z Sixth", l.Content), - l => Assert.Equal("2024-08-19T06:10:05.000Z Seventh", l.Content)); + var watchLogsTask2 = ConsoleLoggingTestHelpers.WatchForLogsAsync(watchLogs2, targetLogCount: 7); + + var watchLogsResults2 = await watchLogsTask2; + Assert.Contains(watchLogsResults2, l => l.Content.Contains("First")); + Assert.Contains(watchLogsResults2, l => l.Content.Contains("Second")); + Assert.Contains(watchLogsResults2, l => l.Content.Contains("Third")); + Assert.Contains(watchLogsResults2, l => l.Content.Contains("Forth")); + Assert.Contains(watchLogsResults2, l => l.Content.Contains("Fifth")); + Assert.Contains(watchLogsResults2, l => l.Content.Contains("Sixth")); + Assert.Contains(watchLogsResults2, l => l.Content.Contains("Seventh")); } private sealed class LogStreamPipes diff --git a/tests/Aspire.Hosting.Tests/ResourceLoggerServiceTests.cs b/tests/Aspire.Hosting.Tests/ResourceLoggerServiceTests.cs index 056e169bf39..02da279c0e3 100644 --- a/tests/Aspire.Hosting.Tests/ResourceLoggerServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceLoggerServiceTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Hosting.Tests.Utils; using Microsoft.Extensions.Logging; using Xunit; @@ -8,31 +9,17 @@ namespace Aspire.Hosting.Tests; public class ResourceLoggerServiceTests { - [Fact] - public void ParseStreamedLogLine() - { - DateTime dateTimeUtc; - - Assert.False(ResourceLoggerService.TryParseContentLineDate("", out _)); - Assert.False(ResourceLoggerService.TryParseContentLineDate(" ", out _)); - Assert.False(ResourceLoggerService.TryParseContentLineDate("ABC-ABC-ABC-ABC-ABC-ABC-ABC-ABC-ABC-ABC-ABC-ABC", out _)); - - Assert.True(ResourceLoggerService.TryParseContentLineDate("2024-08-19T06:01:06.661Z", out dateTimeUtc)); - Assert.Equal(new DateTime(2024, 8, 19, 6, 1, 6, 661, DateTimeKind.Utc), dateTimeUtc); - - Assert.True(ResourceLoggerService.TryParseContentLineDate("2024-08-19T06:10:33.473275911Z", out dateTimeUtc)); - Assert.Equal(new DateTime(2024, 8, 19, 6, 10, 33, 473, 275, DateTimeKind.Utc).Add(TimeSpan.FromTicks(9)), dateTimeUtc); - } - [Fact] public async Task AddingResourceLoggerAnnotationAllowsLogging() { var testResource = new TestResource("myResource"); - var service = new ResourceLoggerService(); + var service = ConsoleLoggingTestHelpers.GetResourceLoggerService(); var logger = service.GetLogger(testResource); var subsLoop = WatchForSubscribers(service); - var logsLoop = WatchForLogs(service, 2, testResource); + + var logsEnumerator1 = service.WatchAsync(testResource).GetAsyncEnumerator(); + var logsLoop = ConsoleLoggingTestHelpers.WatchForLogsAsync(logsEnumerator1, 2); // Wait for subscriber to be added await subsLoop.WaitAsync(TimeSpan.FromSeconds(15)); @@ -44,32 +31,36 @@ public async Task AddingResourceLoggerAnnotationAllowsLogging() // Wait for logs to be read var allLogs = await logsLoop.WaitAsync(TimeSpan.FromSeconds(15)); - Assert.Equal("Hello, world!", allLogs[0].Content); + Assert.Equal("2000-12-29T20:59:59.0000000 Hello, world!", allLogs[0].Content); Assert.False(allLogs[0].IsErrorMessage); - Assert.Equal("Hello, error!", allLogs[1].Content); + Assert.Equal("2000-12-29T20:59:59.0000000 Hello, error!", allLogs[1].Content); Assert.True(allLogs[1].IsErrorMessage); // New sub should get the previous logs subsLoop = WatchForSubscribers(service); - logsLoop = WatchForLogs(service, 2, testResource); - await subsLoop.WaitAsync(TimeSpan.FromSeconds(15)); + var logsEnumerator2 = service.WatchAsync(testResource).GetAsyncEnumerator(); + logsLoop = ConsoleLoggingTestHelpers.WatchForLogsAsync(logsEnumerator2, 2); + await subsLoop.WaitAsync(TimeSpan.FromSeconds(150)); allLogs = await logsLoop.WaitAsync(TimeSpan.FromSeconds(15)); Assert.Equal(2, allLogs.Count); - Assert.Equal("Hello, world!", allLogs[0].Content); - Assert.Equal("Hello, error!", allLogs[1].Content); + Assert.Equal("2000-12-29T20:59:59.0000000 Hello, world!", allLogs[0].Content); + Assert.Equal("2000-12-29T20:59:59.0000000 Hello, error!", allLogs[1].Content); + + await logsEnumerator1.DisposeAsync(); + await logsEnumerator2.DisposeAsync(); } [Fact] public async Task StreamingLogsCancelledAfterComplete() { var testResource = new TestResource("myResource"); - var service = new ResourceLoggerService(); + var service = ConsoleLoggingTestHelpers.GetResourceLoggerService(); var logger = service.GetLogger(testResource); var subsLoop = WatchForSubscribers(service); - var logsLoop = WatchForLogs(service, 2, testResource); + var logsLoop = ConsoleLoggingTestHelpers.WatchForLogsAsync(service, 2, testResource); // Wait for subscriber to be added await subsLoop.WaitAsync(TimeSpan.FromSeconds(15)); @@ -84,30 +75,27 @@ public async Task StreamingLogsCancelledAfterComplete() // Wait for logs to be read var allLogs = await logsLoop.WaitAsync(TimeSpan.FromSeconds(15)); - Assert.Equal("Hello, world!", allLogs[0].Content); - Assert.False(allLogs[0].IsErrorMessage); - - Assert.Equal("Hello, error!", allLogs[1].Content); - Assert.True(allLogs[1].IsErrorMessage); - - Assert.DoesNotContain("The third log", allLogs.Select(x => x.Content)); + Assert.Collection(allLogs, + l => Assert.Equal("2000-12-29T20:59:59.0000000 Hello, world!", l.Content), + l => Assert.Equal("2000-12-29T20:59:59.0000000 Hello, error!", l.Content)); // New sub should not get new logs as the stream is completed - logsLoop = WatchForLogs(service, 100, testResource); + logsLoop = ConsoleLoggingTestHelpers.WatchForLogsAsync(service, 100, testResource); allLogs = await logsLoop.WaitAsync(TimeSpan.FromSeconds(15)); - Assert.Equal(2, allLogs.Count); + Assert.Equal(0, allLogs.Count); } [Fact] public async Task SecondSubscriberGetsBacklog() { var testResource = new TestResource("myResource"); - var service = new ResourceLoggerService(); + var service = ConsoleLoggingTestHelpers.GetResourceLoggerService(); var logger = service.GetLogger(testResource); var subsLoop = WatchForSubscribers(service); - var logsLoop = WatchForLogs(service, 2, testResource); + var logsEnumerator1 = service.WatchAsync(testResource).GetAsyncEnumerator(); + var logsLoop = ConsoleLoggingTestHelpers.WatchForLogsAsync(logsEnumerator1, 2); // Wait for subscriber to be added await subsLoop.WaitAsync(TimeSpan.FromSeconds(15)); @@ -119,34 +107,36 @@ public async Task SecondSubscriberGetsBacklog() // Wait for logs to be read var allLogs = await logsLoop.WaitAsync(TimeSpan.FromSeconds(15)); - Assert.Equal("Hello, world!", allLogs[0].Content); + Assert.Equal("2000-12-29T20:59:59.0000000 Hello, world!", allLogs[0].Content); Assert.False(allLogs[0].IsErrorMessage); - Assert.Equal("Hello, error!", allLogs[1].Content); + Assert.Equal("2000-12-29T20:59:59.0000000 Hello, error!", allLogs[1].Content); Assert.True(allLogs[1].IsErrorMessage); // New sub should get the previous logs (backlog) subsLoop = WatchForSubscribers(service); - logsLoop = WatchForLogs(service, 2, testResource); + var logsEnumerator2 = service.WatchAsync(testResource).GetAsyncEnumerator(); + logsLoop = ConsoleLoggingTestHelpers.WatchForLogsAsync(logsEnumerator2, 2); await subsLoop.WaitAsync(TimeSpan.FromSeconds(15)); allLogs = await logsLoop.WaitAsync(TimeSpan.FromSeconds(15)); Assert.Equal(2, allLogs.Count); - Assert.Equal("Hello, world!", allLogs[0].Content); - Assert.Equal("Hello, error!", allLogs[1].Content); + Assert.Equal("2000-12-29T20:59:59.0000000 Hello, world!", allLogs[0].Content); + Assert.Equal("2000-12-29T20:59:59.0000000 Hello, error!", allLogs[1].Content); // Clear the backlog and ensure new subs only get new logs service.ClearBacklog(testResource.Name); subsLoop = WatchForSubscribers(service); - logsLoop = WatchForLogs(service, 1, testResource); + var logsEnumerator3 = service.WatchAsync(testResource).GetAsyncEnumerator(); + logsLoop = ConsoleLoggingTestHelpers.WatchForLogsAsync(logsEnumerator3, 1); await subsLoop.WaitAsync(TimeSpan.FromSeconds(15)); logger.LogInformation("The third log"); allLogs = await logsLoop.WaitAsync(TimeSpan.FromSeconds(15)); // The backlog should be cleared so only new logs are received Assert.Equal(1, allLogs.Count); - Assert.Equal("The third log", allLogs[0].Content); + Assert.Equal("2000-12-29T20:59:59.0000000 The third log", allLogs[0].Content); } private sealed class TestResource(string name) : Resource(name) @@ -167,21 +157,4 @@ private static Task WatchForSubscribers(ResourceLoggerService service) } }); } - - private static Task> WatchForLogs(ResourceLoggerService service, int targetLogCount, IResource resource) - { - return Task.Run(async () => - { - var logs = new List(); - await foreach (var log in service.WatchAsync(resource)) - { - logs.AddRange(log); - if (logs.Count >= targetLogCount) - { - break; - } - } - return (IReadOnlyList)logs; - }); - } } diff --git a/tests/Shared/ConsoleLogging/ConsoleLoggingTestHelpers.cs b/tests/Shared/ConsoleLogging/ConsoleLoggingTestHelpers.cs new file mode 100644 index 00000000000..9e24abf7d04 --- /dev/null +++ b/tests/Shared/ConsoleLogging/ConsoleLoggingTestHelpers.cs @@ -0,0 +1,66 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Tests.Utils; + +internal static class ConsoleLoggingTestHelpers +{ + public static Task> WatchForLogsAsync(ResourceLoggerService service, int targetLogCount, IResource resource) + { + var watchEnumerable = service.WatchAsync(resource); + return WatchForLogsAsync(watchEnumerable, targetLogCount); + } + + public static Task> WatchForLogsAsync(IAsyncEnumerable> watchEnumerable, int targetLogCount) + { + return Task.Run(async () => + { + var logs = new List(); + await foreach (var log in watchEnumerable) + { + logs.AddRange(log); + if (logs.Count >= targetLogCount) + { + break; + } + } + return (IReadOnlyList)logs; + }); + } + + public static Task> WatchForLogsAsync(IAsyncEnumerator> watchEnumerator, int targetLogCount) + { + return Task.Run(async () => + { + var logs = new List(); + while (await watchEnumerator.MoveNextAsync()) + { + logs.AddRange(watchEnumerator.Current); + if (logs.Count >= targetLogCount) + { + break; + } + } + + return (IReadOnlyList)logs; + }); + } + + public static ResourceLoggerService GetResourceLoggerService() + { + return new ResourceLoggerService + { + TimeProvider = new TestTimeProvider() + }; + } + + private sealed class TestTimeProvider : TimeProvider + { + public static TestTimeProvider Instance = new TestTimeProvider(); + + public override DateTimeOffset GetUtcNow() + { + return new DateTimeOffset(2000, 12, 29, 20, 59, 59, TimeSpan.Zero); + } + } +}