File: ApplicationModel\ResourceLoggerService.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.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Aspire.Shared.ConsoleLogs;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.ApplicationModel;
 
/// <summary>
/// A service that provides loggers for resources to write to.
/// </summary>
public class ResourceLoggerService : IDisposable
{
    // Internal for testing.
    internal TimeProvider TimeProvider { get; set; } = TimeProvider.System;
 
    private readonly ConcurrentDictionary<string, ResourceLoggerState> _loggers = new();
    private readonly CancellationTokenSource _disposing = new();
    private int _disposed;
    private IConsoleLogsService _consoleLogsService = new FakeConsoleLogsService();
    private Action<(string, ResourceLoggerState)>? _loggerAdded;
    private event Action<(string, ResourceLoggerState)> LoggerAdded
    {
        add
        {
            _loggerAdded += value;
 
            foreach (var logger in _loggers)
            {
                value((logger.Key, logger.Value));
            }
        }
        remove
        {
            _loggerAdded -= value;
        }
    }
 
    /// <summary>
    /// Gets the logger for the resource to write to.
    /// </summary>
    /// <param name="resource">The resource name</param>
    /// <returns>An <see cref="ILogger"/> which represents the resource.</returns>
    public ILogger GetLogger(IResource resource)
    {
        ArgumentNullException.ThrowIfNull(resource);
 
        var resourceNames = resource.GetResolvedResourceNames();
        if (resourceNames.Length > 1)
        {
            // If a resource has multiple replicas then return a composite logger that writes to multiple.
            var loggers = new List<ILogger>();
            foreach (var resourceName in resourceNames)
            {
                loggers.Add(GetResourceLoggerState(resourceName).Logger);
            }
 
            return new CompositeLogger(loggers);
        }
        else
        {
            return GetResourceLoggerState(resourceNames[0]).Logger;
        }
    }
 
    private sealed class CompositeLogger(List<ILogger> innerLoggers) : ILogger
    {
        private readonly List<ILogger> _innerLoggers = innerLoggers;
 
        public IDisposable? BeginScope<TState>(TState state) where TState : notnull
        {
            var scopes = new List<IDisposable>();
            foreach (var logger in _innerLoggers)
            {
                if (logger.BeginScope(state) is { } scope)
                {
                    scopes.Add(scope);
                }
            }
 
            if (scopes.Count == 0)
            {
                return null;
            }
            else if (scopes.Count == 1)
            {
                return scopes[0];
            }
            else
            {
                return new CompositeDisposable(scopes);
            }
        }
 
        private sealed class CompositeDisposable(List<IDisposable> disposables) : IDisposable
        {
            private readonly List<IDisposable> _disposables = disposables;
 
            public void Dispose()
            {
                foreach (var disposable in _disposables)
                {
                    disposable.Dispose();
                }
            }
        }
 
        public bool IsEnabled(LogLevel logLevel)
        {
            // All loggers have the same log level.
            return _innerLoggers[0].IsEnabled(logLevel);
        }
 
        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
        {
            foreach (var logger in _innerLoggers)
            {
                logger.Log(logLevel, eventId, state, exception, formatter);
            }
        }
    }
 
    /// <summary>
    /// Gets the logger for the resource to write to.
    /// </summary>
    /// <param name="resourceName">The name of the resource from the Aspire application model.</param>
    /// <returns>An <see cref="ILogger"/> which represents the named resource.</returns>
    public ILogger GetLogger(string resourceName)
    {
        ArgumentNullException.ThrowIfNull(resourceName);
 
        return GetResourceLoggerState(resourceName).Logger;
    }
 
    /// <summary>
    /// Get all logs for a resource. This will return all logs that have been written to the log stream for the resource and then complete.
    /// </summary>
    /// <param name="resource">The resource to get all logs for.</param>
    /// <returns>An async enumerable that returns all logs that have been written to the log stream and then completes.</returns>
    public IAsyncEnumerable<IReadOnlyList<LogLine>> GetAllAsync(IResource resource)
    {
        ArgumentNullException.ThrowIfNull(resource);
 
        var resourceNames = resource.GetResolvedResourceNames();
        if (resourceNames.Length > 1)
        {
            return CombineMultipleAsync(resourceNames, GetAllAsync);
        }
        else
        {
            return GetAllAsync(resourceNames[0]);
        }
    }
 
    /// <summary>
    /// Watch for changes to the log stream for a resource.
    /// </summary>
    /// <param name="resource">The resource to watch for logs.</param>
    /// <returns>An async enumerable that returns the logs as they are written.</returns>
    public IAsyncEnumerable<IReadOnlyList<LogLine>> WatchAsync(IResource resource)
    {
        ArgumentNullException.ThrowIfNull(resource);
 
        var resourceNames = resource.GetResolvedResourceNames();
        if (resourceNames.Length > 1)
        {
            return CombineMultipleAsync(resourceNames, WatchAsync);
        }
        else
        {
            return WatchAsync(resourceNames[0]);
        }
    }
 
    /// <summary>
    /// Get all logs for a resource. This will return all logs that have been written to the log stream for the resource and then complete.
    /// </summary>
    /// <param name="resourceName">The resource name</param>
    /// <returns>An async enumerable that returns all logs that have been written to the log stream and then completes.</returns>
    public IAsyncEnumerable<IReadOnlyList<LogLine>> GetAllAsync(string resourceName)
    {
        ArgumentNullException.ThrowIfNull(resourceName);
 
        return GetResourceLoggerState(resourceName).GetAllAsync(_consoleLogsService);
    }
 
    /// <summary>
    /// Watch for changes to the log stream for a resource.
    /// </summary>
    /// <param name="resourceName">The resource name</param>
    /// <returns>An async enumerable that returns the logs as they are written.</returns>
    public IAsyncEnumerable<IReadOnlyList<LogLine>> WatchAsync(string resourceName)
    {
        ArgumentNullException.ThrowIfNull(resourceName);
 
        return GetResourceLoggerState(resourceName).WatchAsync();
    }
 
    internal IDisposable Subscribe(string resourceName, Action<IReadOnlyList<LogLine>> onLogs)
    {
        ArgumentNullException.ThrowIfNull(resourceName);
        ArgumentNullException.ThrowIfNull(onLogs);
 
        return GetResourceLoggerState(resourceName).Subscribe(onLogs);
    }
 
    internal Task WaitForCompletionAsync(string resourceName, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(resourceName);
 
        return GetResourceLoggerState(resourceName).WaitForCompletionAsync(cancellationToken);
    }
 
    /// <summary>
    /// Watch for subscribers to the log stream for a resource.
    /// </summary>
    /// <returns>
    /// An async enumerable that returns when the first subscriber is added to a log,
    /// or when the last subscriber is removed.
    /// </returns>
    public async IAsyncEnumerable<LogSubscriber> WatchAnySubscribersAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        var channel = Channel.CreateUnbounded<LogSubscriber>();
        var subscribedStates = new List<(ResourceLoggerState State, Action<bool> Handler)>();
 
        // Create a linked token that cancels when either the service is disposing or the caller cancels.
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_disposing.Token, cancellationToken);
 
        void OnLoggerAdded((string Name, ResourceLoggerState State) loggerItem)
        {
            var (name, state) = loggerItem;
 
            Action<bool> handler = (hasSubscribers) =>
            {
                channel.Writer.TryWrite(new(name, hasSubscribers));
            };
 
            state.OnSubscribersChanged += handler;
            subscribedStates.Add((state, handler));
        }
 
        try
        {
            LoggerAdded += OnLoggerAdded;
 
            await foreach (var entry in channel.Reader.ReadAllAsync(linkedCts.Token).ConfigureAwait(false))
            {
                yield return entry;
            }
        }
        finally
        {
            LoggerAdded -= OnLoggerAdded;
 
            // Unsubscribe from all OnSubscribersChanged events to prevent memory leaks
            foreach (var (state, handler) in subscribedStates)
            {
                state.OnSubscribersChanged -= handler;
            }
 
            channel.Writer.Complete();
        }
    }
 
    /// <summary>
    /// Completes the log stream for the resource.
    /// </summary>
    /// <param name="resource">The <see cref="IResource"/>.</param>
    public void Complete(IResource resource)
    {
        ArgumentNullException.ThrowIfNull(resource);
 
        var resourceNames = resource.GetResolvedResourceNames();
        foreach (var resourceName in resourceNames)
        {
            if (_loggers.TryGetValue(resourceName, out var logger))
            {
                logger.Complete();
            }
        }
    }
 
    /// <summary>
    /// Completes the log stream for the resource.
    /// </summary>
    /// <param name="name">The name of the resource.</param>
    public void Complete(string name)
    {
        ArgumentNullException.ThrowIfNull(name);
 
        if (_loggers.TryGetValue(name, out var logger))
        {
            logger.Complete();
        }
    }
 
    /// <summary>
    /// Clears the log stream's backlog for the resource.
    /// </summary>
    public void ClearBacklog(string resourceName)
    {
        ArgumentNullException.ThrowIfNull(resourceName);
 
        if (_loggers.TryGetValue(resourceName, out var logger))
        {
            logger.ClearBacklog();
        }
    }
 
    private static async IAsyncEnumerable<IReadOnlyList<LogLine>> CombineMultipleAsync(string[] resourceNames, Func<string, IAsyncEnumerable<IReadOnlyList<LogLine>>> fetch)
    {
        var channel = Channel.CreateUnbounded<IReadOnlyList<LogLine>>();
        var readTasks = resourceNames.Select(async (name) =>
        {
            await foreach (var logLines in fetch(name).ConfigureAwait(false))
            {
                channel.Writer.TryWrite(logLines);
            }
        });
 
        var completionTask = Task.Run(async () =>
        {
            try
            {
                await Task.WhenAll(readTasks).ConfigureAwait(false);
            }
            finally
            {
                channel.Writer.Complete();
            }
        });
 
        await foreach (var item in channel.Reader.ReadAllAsync().ConfigureAwait(false))
        {
            yield return item;
        }
 
        await completionTask.ConfigureAwait(false);
    }
 
    // Internal for testing.
    internal ResourceLoggerState GetResourceLoggerState(string resourceName) =>
        _loggers.GetOrAdd(resourceName, (name, context) =>
        {
            var state = new ResourceLoggerState(name, TimeProvider);
            context._loggerAdded?.Invoke((name, state));
            return state;
        },
        this);
 
    internal bool HasActiveSubscribers(string resourceName)
    {
        return _loggers.TryGetValue(resourceName, out var logger) && logger.HasActiveSubscribers;
    }
 
    internal void AddLogEntries(string resourceName, IReadOnlyList<LogEntry> logEntries, bool inMemorySource, bool skipExisting)
    {
        ArgumentNullException.ThrowIfNull(resourceName);
        ArgumentNullException.ThrowIfNull(logEntries);
 
        GetResourceLoggerState(resourceName).AddLogs(logEntries, inMemorySource, skipExisting);
    }
 
    internal Dictionary<string, ResourceLoggerState> Loggers => _loggers.ToDictionary();
 
    /// <summary>
    /// A logger for the resource to write to.
    /// </summary>
    internal sealed class ResourceLoggerState
    {
        private const int MaxLogCount = 10_000;
 
        private readonly ResourceLogger _logger;
        private readonly CancellationTokenSource _logStreamCts = new();
        private readonly TaskCompletionSource _logStreamCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously);
        private readonly object _lock = new();
 
        private readonly CircularBuffer<LogEntry> _inMemoryEntries = new(MaxLogCount);
        private readonly LogEntries _backlog = new(MaxLogCount) { BaseLineNumber = 0 };
        private readonly string _name;
        private readonly TimeProvider _timeProvider;
 
        /// <summary>
        /// Creates a new <see cref="ResourceLoggerState"/>.
        /// </summary>
        public ResourceLoggerState(string name, TimeProvider timeProvider)
        {
            _logger = new ResourceLogger(this);
            _name = name;
            _timeProvider = timeProvider;
        }
 
        private Action<bool>? _onSubscribersChanged;
 
        // Internal test hook for pausing synchronous Subscribe after it has attached its callback
        // but before it delivers the backlog. This makes the backlog/live transition deterministic
        // without sleeps.
        internal Action? SynchronousSubscribeRegistered { get; set; }
 
        public event Action<bool> OnSubscribersChanged
        {
            add
            {
                _onSubscribersChanged += value;
 
                var hasSubscribers = false;
 
                lock (_lock)
                {
                    if (_onNewLog is not null) // we have subscribers
                    {
                        hasSubscribers = true;
                    }
                }
 
                if (hasSubscribers)
                {
                    value(hasSubscribers);
                }
            }
            remove
            {
                _onSubscribersChanged -= value;
            }
        }
 
        public async IAsyncEnumerable<IReadOnlyList<LogLine>> GetAllAsync(IConsoleLogsService consoleLogsService, [EnumeratorCancellation] CancellationToken cancellationToken = default)
        {
            var consoleLogsEnumerable = consoleLogsService.GetAllLogsAsync(_name, cancellationToken);
 
            List<LogEntry> inMemoryEntries;
            lock (_lock)
            {
                inMemoryEntries = _inMemoryEntries.ToList();
            }
 
            var lineNumber = 0;
            yield return CreateLogLines(ref lineNumber, inMemoryEntries);
 
            await foreach (var item in consoleLogsEnumerable.ConfigureAwait(false))
            {
                yield return CreateLogLines(ref lineNumber, item);
            }
        }
 
        /// <summary>
        /// Watch for changes to the log stream for a resource.
        /// </summary>
        /// <returns>The log stream for the resource.</returns>
        public async IAsyncEnumerable<IReadOnlyList<LogLine>> WatchAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
        {
            // 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<LogEntry>();
 
            using var _ = _logStreamCts.Token.Register(() => channel.Writer.TryComplete());
 
            // No need to lock in the log method because TryWrite/TryComplete are already thread safe.
            void Log(LogEntry log) => channel.Writer.TryWrite(log);
 
            LogEntry[] backlogSnapshot;
            lock (_lock)
            {
                // If there are no subscribers then the backlog must be empty. Populate it with any in-memory logs.
                if (!HasSubscribers)
                {
                    Debug.Assert(_backlog.EntriesCount == 0, "The backlog should be empty if there are no subscribers.");
 
                    // Populate backlog with in-memory log messages on first subscription.
                    foreach (var logEntry in _inMemoryEntries)
                    {
                        _backlog.InsertSorted(logEntry);
                    }
                }
 
                backlogSnapshot = GetBacklogSnapshot();
                OnNewLog += Log;
            }
 
            try
            {
                if (backlogSnapshot.Length > 0)
                {
                    yield return CreateLogLines(ref lineNumber, backlogSnapshot);
                }
 
                await foreach (var entry in channel.GetBatchesAsync(cancellationToken: cancellationToken).ConfigureAwait(false))
                {
                    yield return CreateLogLines(ref lineNumber, entry);
                }
            }
            finally
            {
                lock (_lock)
                {
                    OnNewLog -= Log;
                    channel.Writer.TryComplete();
                }
            }
        }
 
        public IDisposable Subscribe(Action<IReadOnlyList<LogLine>> onLogs)
        {
            // This is not a replacement for WatchAsync. WatchAsync intentionally decouples
            // producers and consumers through a channel, which is the right shape for dashboard
            // and backchannel consumers. This path is for ResourceLoggerForwarderService, which
            // forwards resource logs into host ILogger when resource logging is enabled. That
            // forwarder needs stronger ordering: after DCP flushes logs for a terminal state,
            // those logs must reach ILogger before ResourceNotificationService publishes the
            // terminal state that unblocks WaitForResourceAsync. Subscribe provides that
            // producer-thread delivery once the backlog has been drained.
 
            // Line number always restarts from 1 when watching logs.
            var lineNumber = 1;
            var subscriberLock = new object();
            var backlogDelivered = false;
 
            // Logs can arrive after we attach OnNewLog but before the backlog has been delivered.
            // Queue those entries so the subscriber sees backlog first without dropping live logs
            // from that small transition window.
            var pendingLiveEntries = new List<LogEntry>();
 
            void Log(LogEntry log)
            {
                if (_logStreamCts.IsCancellationRequested)
                {
                    return;
                }
 
                IReadOnlyList<LogLine>? logLines = null;
 
                lock (subscriberLock)
                {
                    if (!backlogDelivered)
                    {
                        pendingLiveEntries.Add(log);
                        return;
                    }
 
                    logLines = CreateLogLines(ref lineNumber, [log]);
                }
 
                onLogs(logLines);
            }
 
            LogEntry[] backlogSnapshot;
            lock (_lock)
            {
                // If there are no subscribers then the backlog must be empty. Populate it with any in-memory logs.
                if (!HasSubscribers)
                {
                    Debug.Assert(_backlog.EntriesCount == 0, "The backlog should be empty if there are no subscribers.");
 
                    // Populate backlog with in-memory log messages on first subscription.
                    foreach (var logEntry in _inMemoryEntries)
                    {
                        _backlog.InsertSorted(logEntry);
                    }
                }
 
                backlogSnapshot = GetBacklogSnapshot();
                OnNewLog += Log;
            }
 
            var subscription = new Subscription(this, Log);
 
            try
            {
                SynchronousSubscribeRegistered?.Invoke();
 
                var batches = new List<IReadOnlyList<LogLine>>();
                if (backlogSnapshot.Length > 0)
                {
                    batches.Add(CreateLogLines(ref lineNumber, backlogSnapshot));
                }
 
                // Drain the backlog and any live entries that arrived during backlog delivery before
                // returning the subscription. After this point, future AddLogs calls invoke onLogs
                // synchronously from the producer's thread.
                while (true)
                {
                    foreach (var batch in batches)
                    {
                        onLogs(batch);
                    }
 
                    lock (subscriberLock)
                    {
                        if (pendingLiveEntries.Count == 0)
                        {
                            backlogDelivered = true;
                            break;
                        }
 
                        batches = [CreateLogLines(ref lineNumber, pendingLiveEntries)];
                        pendingLiveEntries.Clear();
                    }
                }
            }
            catch
            {
                subscription.Dispose();
                throw;
            }
 
            return subscription;
        }
 
        public Task WaitForCompletionAsync(CancellationToken cancellationToken)
        {
            return _logStreamCompletion.Task.WaitAsync(cancellationToken);
        }
 
        private bool HasSubscribers
        {
            get
            {
                Debug.Assert(Monitor.IsEntered(_lock));
                return _onNewLog != null;
            }
        }
 
        internal bool HasActiveSubscribers
        {
            get
            {
                lock (_lock)
                {
                    // Completed streams should not trigger a terminal-state snapshot flush. The
                    // subscriber might still be disposing, but no more logs should be delivered.
                    return !_logStreamCts.IsCancellationRequested && HasSubscribers;
                }
            }
        }
 
        // This provides the fan out to multiple subscribers.
        private Action<LogEntry>? _onNewLog;
        private event Action<LogEntry> OnNewLog
        {
            add
            {
                Debug.Assert(Monitor.IsEntered(_lock));
 
                // 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)
                {
                    _onSubscribersChanged?.Invoke(true);
                }
            }
            remove
            {
                Debug.Assert(Monitor.IsEntered(_lock));
 
                _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);
                }
            }
        }
 
        /// <summary>
        /// The logger for the resource to write to. This will write updates to the live log stream for this resource.
        /// </summary>
        public ILogger Logger => _logger;
 
        /// <summary>
        /// Close the log stream for the resource. Future subscribers will not receive any updates and will complete immediately.
        /// </summary>
        public void Complete()
        {
            // REVIEW: Do we clean up the backlog?
            _logStreamCts.Cancel();
            _logStreamCompletion.TrySetResult();
        }
 
        public void ClearBacklog()
        {
            lock (_lock)
            {
                _backlog.Clear(keepActivePauseEntries: false);
                _backlog.BaseLineNumber = 0;
            }
        }
 
        internal LogEntry[] GetBacklogSnapshot()
        {
            lock (_lock)
            {
                return [.. _backlog.GetEntries()];
            }
        }
 
        public void AddLog(LogEntry logEntry, bool inMemorySource)
        {
            lock (_lock)
            {
                // Only add logs into the backlog if there are subscribers. If there aren't subscribers then
                // logs are replayed into this collection from various sources (DCP, in-memory).
                if (HasSubscribers)
                {
                    _backlog.InsertSorted(logEntry);
                }
 
                // Keep replayable logs in their own collection. These logs are replayed into
                // the backlog when a log watch starts without fetching them from an external source.
                if (inMemorySource)
                {
                    _inMemoryEntries.Add(logEntry);
                }
            }
 
            _onNewLog?.Invoke(logEntry);
        }
 
        public void AddLogs(IReadOnlyList<LogEntry> logEntries, bool inMemorySource, bool skipExisting)
        {
            if (logEntries.Count == 0)
            {
                return;
            }
 
            List<LogEntry>? addedEntries = null;
            lock (_lock)
            {
                Dictionary<LogEntryKey, int>? existingLogCounts = null;
                if (skipExisting)
                {
                    // This path is intentionally reserved for one-shot replay sources, such as
                    // terminal-state log snapshots, that can overlap with entries already observed
                    // from another source. It rebuilds counts from the replay target, so steady-state
                    // follow streams should avoid skipExisting and deduplicate only against the small
                    // overlap window tracked by DcpResourceWatcher.
                    //
                    // Use occurrence counts instead of a set so repeated identical log lines are
                    // preserved while only overlapping copies from the later source are skipped.
                    var existingEntries = HasSubscribers ? _backlog.GetEntries() : _inMemoryEntries;
                    existingLogCounts = [];
 
                    foreach (var existingEntry in existingEntries)
                    {
                        IncrementCount(existingLogCounts, LogEntryKey.Create(existingEntry));
                    }
                }
 
                foreach (var logEntry in logEntries)
                {
                    if (existingLogCounts is not null)
                    {
                        var key = LogEntryKey.Create(logEntry);
                        if (existingLogCounts.TryGetValue(key, out var count) && count > 0)
                        {
                            existingLogCounts[key] = count - 1;
                            continue;
                        }
                    }
 
                    addedEntries ??= [];
 
                    // Only add logs into the backlog if there are subscribers. If there aren't subscribers then
                    // logs are replayed into this collection from various sources (DCP, in-memory).
                    if (HasSubscribers)
                    {
                        _backlog.InsertSorted(logEntry);
                    }
 
                    // Keep replayable logs in their own collection. These logs are replayed into
                    // the backlog when a log watch starts without fetching them from an external source.
                    if (inMemorySource)
                    {
                        _inMemoryEntries.Add(logEntry);
                    }
 
                    addedEntries.Add(logEntry);
                }
            }
 
            if (addedEntries is null)
            {
                return;
            }
 
            foreach (var logEntry in addedEntries)
            {
                _onNewLog?.Invoke(logEntry);
            }
 
            static void IncrementCount(Dictionary<LogEntryKey, int> counts, LogEntryKey key)
            {
                counts.TryGetValue(key, out var count);
                counts[key] = count + 1;
            }
        }
 
        private sealed class Subscription(ResourceLoggerState loggerState, Action<LogEntry> log) : IDisposable
        {
            public void Dispose()
            {
                lock (loggerState._lock)
                {
                    loggerState.OnNewLog -= log;
                }
            }
        }
 
        private sealed class ResourceLogger(ResourceLoggerState loggerState) : ILogger
        {
            IDisposable? ILogger.BeginScope<TState>(TState state) => null;
 
            bool ILogger.IsEnabled(LogLevel logLevel) => true;
 
            public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
            {
                if (loggerState._logStreamCts.IsCancellationRequested)
                {
                    // Noop if logging after completing the stream
                    return;
                }
 
                var logTime = loggerState._timeProvider.GetUtcNow().UtcDateTime;
 
                var logMessage = formatter(state, exception) + (exception is null ? "" : $"\n{exception}");
                var isErrorMessage = logLevel >= LogLevel.Error;
 
                loggerState.AddLog(LogEntry.Create(logTime, logMessage, isErrorMessage), inMemorySource: true);
            }
        }
    }
 
    private static LogLine[] CreateLogLines(ref int lineNumber, IReadOnlyList<LogEntry> entries)
    {
        var logs = new LogLine[entries.Count];
        for (var i = 0; i < entries.Count; i++)
        {
            var entry = entries[i];
            var content = entry.Content ?? string.Empty;
            if (entry.Timestamp != null)
            {
                content = entry.Timestamp.Value.ToString(KnownFormats.ConsoleLogsTimestampFormat, CultureInfo.InvariantCulture) + " " + content;
            }
 
            logs[i] = new LogLine(lineNumber, content, entry.Type == LogEntryType.Error);
            lineNumber++;
        }
 
        return logs;
    }
 
    internal void SetConsoleLogsService(IConsoleLogsService consoleLogsService)
    {
        _consoleLogsService = consoleLogsService;
    }
 
    /// <summary>
    /// Disposes the service and completes all log streams.
    /// </summary>
    public void Dispose()
    {
        if (Interlocked.Exchange(ref _disposed, 1) != 0)
        {
            return;
        }
 
        // Complete all loggers to signal that no more logs will be written.
        foreach (var logger in _loggers)
        {
            logger.Value.Complete();
        }
 
        // Cancel but don't dispose - other methods may still be accessing _disposing.Token
        // The CTS will be garbage collected with the service.
        _disposing.Cancel();
    }
 
    private sealed class FakeConsoleLogsService : IConsoleLogsService
    {
        public IAsyncEnumerable<IReadOnlyList<LogEntry>> GetAllLogsAsync(string resourceName, CancellationToken cancellationToken)
        {
            throw new InvalidOperationException($"Getting all logs requires the {nameof(ResourceLoggerService)} instance created by DI.");
        }
    }
}
 
/// <summary>
/// Represents a log subscriber for a resource.
/// </summary>
/// <param name="Name">The the resource name.</param>
/// <param name="AnySubscribers">Determines if there are any subscribers.</param>
public readonly record struct LogSubscriber(string Name, bool AnySubscribers);