File: src\Shared\ConsoleLogs\LogEntries.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// 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;
 
// Type is shared by dashboard and hosting.
// It needs to be public in dashboard so it can be bound to a parameter.
// It needs to be internal in hosting because we don't want to expose it as public API.
[DebuggerDisplay("Count = {EntriesCount}")]
#if ASPIRE_DASHBOARD
public sealed class LogEntries(int maximumEntryCount)
#else
internal sealed class LogEntries(int maximumEntryCount)
#endif
{
    private readonly List<LogPauseViewModel> _pauseViewModels = [];
    private readonly CircularBuffer<LogEntry> _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<LogEntry> GetEntries() => _logEntries;
 
    public int EntriesCount => _logEntries.Count;
 
    public void Clear(bool keepActivePauseEntries)
    {
        if (keepActivePauseEntries)
        {
            // Don't remove pause VMs or their entries that are still active.
            _pauseViewModels.RemoveAll(pause => pause.EndTime is not null);
            foreach (var pauseVM in _pauseViewModels)
            {
                // Reset filtered count to zero because all the entries have been cleared.
                pauseVM.FilteredCount = 0;
            }
 
            var pauseEntries = _logEntries.Where(e => e.Type == LogEntryType.Pause && _pauseViewModels.Contains(e.Pause!)).ToList();
            _logEntries.Clear();
            foreach (var pauseEntry in pauseEntries)
            {
                _logEntries.Add(pauseEntry);
            }
        }
        else
        {
            _pauseViewModels.Clear();
            _logEntries.Clear();
        }
 
        BaseLineNumber = null;
    }
 
    public bool ProcessPauseFilters(LogEntry logEntry)
    {
        if (logEntry.Timestamp is null)
        {
            return false;
        }
 
        foreach (var pauseVM in _pauseViewModels)
        {
            if (pauseVM.Contains(logEntry.Timestamp.Value))
            {
                pauseVM.FilteredCount += 1;
                return true;
            }
        }
 
        return false;
    }
 
    /// <summary>
    /// Insert a log entry at the correct position in the list of log entries according to its timestamp.
    /// </summary>
    /// <param name="logLine"></param>
    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 or the entry is a pause then add to the end.
        if (logEntry.Timestamp == null || logEntry.Type is LogEntryType.Pause)
        {
            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)
        {
            if (logEntry.Type is LogEntryType.Pause)
            {
                _pauseViewModels.Add(logEntry.Pause!);
                _logEntries.Insert(index, logEntry);
                return;
            }
 
            // 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 if (_logEntries[index - 1].Pause is { } pause)
            {
                Debug.Assert(pause.EndTime is not null, "Pause should have ended before trying to insert another log.");
 
                int? previousLineNumber = null;
                for (var i = index - 1; i >= 0; i--)
                {
                    if (_logEntries[i] is { Type: not LogEntryType.Pause } entry)
                    {
                        previousLineNumber = entry.LineNumber;
                        break;
                    }
                }
 
                if (previousLineNumber is not null)
                {
                    logEntry.LineNumber = previousLineNumber.Value + pause.FilteredCount + 1;
                }
                else
                {
                    Debug.Assert(BaseLineNumber is not null);
                    logEntry.LineNumber = BaseLineNumber.Value + pause.FilteredCount;
                }
            }
            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++;
            }
        }
    }
}