Skip to content

Commit b00e8f7

Browse files
Mitch DennyCopilot
andcommitted
Add backchannel log replay for TUI support
Add server-side backchannel APIs to support the upcoming aspire monitor TUI: - Add log replay buffer (1000 entries) to BackchannelLoggerProvider so late-connecting clients see log history, using Queue<T> for O(1) eviction - Replay buffered entries at start of GetAppHostLogEntriesAsync stream - Expose GetAppHostLogEntriesAsync on AuxiliaryBackchannelRpcTarget - Update BackchannelLoggerProvider DI registration to forwarding pattern so AppHostRpcTarget can resolve the concrete type for replay - Fix Dispose to use TryComplete to handle multiple disposal calls - Add tests for replay buffer behavior (fill, eviction, snapshot isolation) All changes are additive and backward-compatible. No CLI or TUI code included. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c62d96b commit b00e8f7

File tree

5 files changed

+169
-4
lines changed

5 files changed

+169
-4
lines changed

src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ public async IAsyncEnumerable<BackchannelLogEntry> GetAppHostLogEntriesAsync([En
3535
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _shutdownCts.Token);
3636
var linkedToken = linkedCts.Token;
3737

38+
// Replay buffered entries first so late-connecting clients see history
39+
var loggerProvider = serviceProvider.GetService<BackchannelLoggerProvider>();
40+
if (loggerProvider is not null)
41+
{
42+
foreach (var entry in loggerProvider.GetReplaySnapshot())
43+
{
44+
yield return entry;
45+
}
46+
}
47+
3848
Channel<BackchannelLogEntry>? channel = null;
3949

4050
try

src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,16 @@ private HttpClientTransport CreateHttpClientTransport(Uri endpointUri)
988988

989989
#endregion
990990

991+
/// <summary>
992+
/// Streams AppHost log entries from the hosting process.
993+
/// Delegates to <see cref="AppHostRpcTarget.GetAppHostLogEntriesAsync"/>.
994+
/// </summary>
995+
public IAsyncEnumerable<BackchannelLogEntry> GetAppHostLogEntriesAsync(CancellationToken cancellationToken = default)
996+
{
997+
var rpcTarget = serviceProvider.GetRequiredService<AppHostRpcTarget>();
998+
return rpcTarget.GetAppHostLogEntriesAsync(cancellationToken);
999+
}
1000+
9911001
/// <summary>
9921002
/// Converts a JsonElement to its underlying CLR type for proper serialization.
9931003
/// </summary>

src/Aspire.Hosting/Backchannel/BackchannelLoggerProvider.cs

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ namespace Aspire.Hosting.Backchannel;
1010
internal class BackchannelLoggerProvider : ILoggerProvider
1111
{
1212
private readonly Channel<BackchannelLogEntry> _channel = Channel.CreateUnbounded<BackchannelLogEntry>();
13+
private readonly Queue<BackchannelLogEntry> _replayBuffer = new();
14+
private readonly object _replayLock = new();
15+
private const int MaxReplayEntries = 1000;
1316
private readonly IServiceProvider _serviceProvider;
1417
private readonly object _channelRegisteredLock = new();
1518
private readonly CancellationTokenSource _backgroundChannelRegistrationCts = new();
@@ -21,6 +24,29 @@ public BackchannelLoggerProvider(IServiceProvider serviceProvider)
2124
_serviceProvider = serviceProvider;
2225
}
2326

27+
/// <summary>
28+
/// Gets a snapshot of buffered log entries for replay to late-connecting clients.
29+
/// </summary>
30+
internal List<BackchannelLogEntry> GetReplaySnapshot()
31+
{
32+
lock (_replayLock)
33+
{
34+
return [.. _replayBuffer];
35+
}
36+
}
37+
38+
internal void AddToReplayBuffer(BackchannelLogEntry entry)
39+
{
40+
lock (_replayLock)
41+
{
42+
if (_replayBuffer.Count >= MaxReplayEntries)
43+
{
44+
_replayBuffer.Dequeue();
45+
}
46+
_replayBuffer.Enqueue(entry);
47+
}
48+
}
49+
2450
private void RegisterLogChannel()
2551
{
2652
// Why do we execute this on a background task? This method is spawned on a background
@@ -49,17 +75,17 @@ public ILogger CreateLogger(string categoryName)
4975
}
5076
}
5177

52-
return new BackchannelLogger(categoryName, _channel);
78+
return new BackchannelLogger(categoryName, _channel, this);
5379
}
5480

5581
public void Dispose()
5682
{
5783
_backgroundChannelRegistrationCts.Cancel();
58-
_channel.Writer.Complete();
84+
_channel.Writer.TryComplete();
5985
}
6086
}
6187

62-
internal class BackchannelLogger(string categoryName, Channel<BackchannelLogEntry> channel) : ILogger
88+
internal class BackchannelLogger(string categoryName, Channel<BackchannelLogEntry> channel, BackchannelLoggerProvider provider) : ILogger
6389
{
6490
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
6591
{
@@ -84,6 +110,7 @@ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Except
84110
Message = formatter(state, exception),
85111
};
86112

113+
provider.AddToReplayBuffer(entry);
87114
channel.Writer.TryWrite(entry);
88115
}
89116
}

src/Aspire.Hosting/DistributedApplicationBuilder.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,8 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)
188188

189189
_innerBuilder.Services.AddSingleton(TimeProvider.System);
190190

191-
_innerBuilder.Services.AddSingleton<ILoggerProvider, BackchannelLoggerProvider>();
191+
_innerBuilder.Services.AddSingleton<BackchannelLoggerProvider>();
192+
_innerBuilder.Services.AddSingleton<ILoggerProvider>(sp => sp.GetRequiredService<BackchannelLoggerProvider>());
192193
_innerBuilder.Logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Warning);
193194
_innerBuilder.Logging.AddFilter("Microsoft.AspNetCore.Server.Kestrel", LogLevel.Error);
194195
_innerBuilder.Logging.AddFilter("Aspire.Hosting.Dashboard", LogLevel.Error);
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.Extensions.DependencyInjection;
5+
using Microsoft.Extensions.Logging;
6+
7+
namespace Aspire.Hosting.Backchannel;
8+
9+
public class BackchannelLoggerProviderTests
10+
{
11+
[Fact]
12+
public void ReplayBuffer_ReturnsLoggedEntries()
13+
{
14+
var services = new ServiceCollection();
15+
services.AddSingleton<BackchannelLoggerProvider>();
16+
services.AddSingleton<ILoggerProvider>(sp => sp.GetRequiredService<BackchannelLoggerProvider>());
17+
services.AddSingleton<AppHostRpcTarget>();
18+
services.AddLogging();
19+
20+
// Add required AppHostRpcTarget dependencies with minimal stubs
21+
services.AddSingleton(Aspire.Hosting.Tests.Utils.ResourceNotificationServiceTestHelpers.Create());
22+
services.AddSingleton<Aspire.Hosting.Pipelines.PipelineActivityReporter>();
23+
services.AddSingleton(new DistributedApplicationOptions { AssemblyName = "Test" });
24+
services.AddSingleton<Microsoft.Extensions.Hosting.IHostApplicationLifetime, TestHostApplicationLifetime>();
25+
26+
using var sp = services.BuildServiceProvider();
27+
var provider = sp.GetRequiredService<BackchannelLoggerProvider>();
28+
29+
var logger = provider.CreateLogger("TestCategory");
30+
logger.LogInformation("Message 1");
31+
logger.LogWarning("Message 2");
32+
logger.LogError("Message 3");
33+
34+
var snapshot = provider.GetReplaySnapshot();
35+
36+
Assert.Equal(3, snapshot.Count);
37+
Assert.Equal("Message 1", snapshot[0].Message);
38+
Assert.Equal("Message 2", snapshot[1].Message);
39+
Assert.Equal("Message 3", snapshot[2].Message);
40+
Assert.Equal("TestCategory", snapshot[0].CategoryName);
41+
Assert.Equal(LogLevel.Information, snapshot[0].LogLevel);
42+
Assert.Equal(LogLevel.Warning, snapshot[1].LogLevel);
43+
Assert.Equal(LogLevel.Error, snapshot[2].LogLevel);
44+
}
45+
46+
[Fact]
47+
public void ReplayBuffer_EvictsOldestWhenFull()
48+
{
49+
var services = new ServiceCollection();
50+
services.AddSingleton<BackchannelLoggerProvider>();
51+
services.AddSingleton<ILoggerProvider>(sp => sp.GetRequiredService<BackchannelLoggerProvider>());
52+
services.AddSingleton<AppHostRpcTarget>();
53+
services.AddLogging();
54+
55+
services.AddSingleton(Aspire.Hosting.Tests.Utils.ResourceNotificationServiceTestHelpers.Create());
56+
services.AddSingleton<Aspire.Hosting.Pipelines.PipelineActivityReporter>();
57+
services.AddSingleton(new DistributedApplicationOptions { AssemblyName = "Test" });
58+
services.AddSingleton<Microsoft.Extensions.Hosting.IHostApplicationLifetime, TestHostApplicationLifetime>();
59+
60+
using var sp = services.BuildServiceProvider();
61+
var provider = sp.GetRequiredService<BackchannelLoggerProvider>();
62+
63+
var logger = provider.CreateLogger("TestCategory");
64+
65+
// Write 1001 entries — the first should be evicted
66+
for (var i = 0; i < 1001; i++)
67+
{
68+
logger.LogInformation("Message {Index}", i);
69+
}
70+
71+
var snapshot = provider.GetReplaySnapshot();
72+
73+
Assert.Equal(1000, snapshot.Count);
74+
// First entry should be "Message 1" (index 0 was evicted)
75+
Assert.Equal("Message 1", snapshot[0].Message);
76+
Assert.Equal("Message 1000", snapshot[999].Message);
77+
}
78+
79+
[Fact]
80+
public void ReplaySnapshot_ReturnsIndependentCopy()
81+
{
82+
var services = new ServiceCollection();
83+
services.AddSingleton<BackchannelLoggerProvider>();
84+
services.AddSingleton<ILoggerProvider>(sp => sp.GetRequiredService<BackchannelLoggerProvider>());
85+
services.AddSingleton<AppHostRpcTarget>();
86+
services.AddLogging();
87+
88+
services.AddSingleton(Aspire.Hosting.Tests.Utils.ResourceNotificationServiceTestHelpers.Create());
89+
services.AddSingleton<Aspire.Hosting.Pipelines.PipelineActivityReporter>();
90+
services.AddSingleton(new DistributedApplicationOptions { AssemblyName = "Test" });
91+
services.AddSingleton<Microsoft.Extensions.Hosting.IHostApplicationLifetime, TestHostApplicationLifetime>();
92+
93+
using var sp = services.BuildServiceProvider();
94+
var provider = sp.GetRequiredService<BackchannelLoggerProvider>();
95+
96+
var logger = provider.CreateLogger("TestCategory");
97+
logger.LogInformation("Before snapshot");
98+
99+
var snapshot1 = provider.GetReplaySnapshot();
100+
101+
logger.LogInformation("After snapshot");
102+
103+
var snapshot2 = provider.GetReplaySnapshot();
104+
105+
// First snapshot should not be affected by subsequent writes
106+
Assert.Single(snapshot1);
107+
Assert.Equal(2, snapshot2.Count);
108+
}
109+
110+
private sealed class TestHostApplicationLifetime : Microsoft.Extensions.Hosting.IHostApplicationLifetime
111+
{
112+
public CancellationToken ApplicationStarted => CancellationToken.None;
113+
public CancellationToken ApplicationStopping => CancellationToken.None;
114+
public CancellationToken ApplicationStopped => CancellationToken.None;
115+
public void StopApplication() { }
116+
}
117+
}

0 commit comments

Comments
 (0)