Skip to content

Commit 70105d0

Browse files
authored
Fix dashboard log parsing in host (#5425)
1 parent 28e5c75 commit 70105d0

File tree

4 files changed

+231
-1
lines changed

4 files changed

+231
-1
lines changed

src/Aspire.Hosting/Aspire.Hosting.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
<Compile Include="$(SharedDir)LaunchProfile.cs" Link="LaunchProfile.cs" />
3838
<Compile Include="$(SharedDir)LaunchSettingsSerializerContext.cs" Link="LaunchSettingsSerializerContext.cs" />
3939
<Compile Include="$(SharedDir)SecretsStore.cs" Link="Utils\SecretsStore.cs" />
40+
<Compile Include="$(SharedDir)ConsoleLogs\TimestampParser.cs" Link="Utils\ConsoleLogs\TimestampParser.cs" />
4041
</ItemGroup>
4142

4243
<ItemGroup>

src/Aspire.Hosting/Dashboard/DashboardLifecycleHook.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Text.Json;
77
using System.Text.Json.Nodes;
88
using System.Text.Json.Serialization;
9+
using Aspire.Dashboard.ConsoleLogs;
910
using Aspire.Dashboard.Model;
1011
using Aspire.Hosting.ApplicationModel;
1112
using Aspire.Hosting.Dcp;
@@ -300,7 +301,13 @@ private static async Task WatchResourceLogsAsync(string dashboardResourceId,
300301

301302
try
302303
{
303-
logMessage = JsonSerializer.Deserialize(logLine.Content, DashboardLogMessageContext.Default.DashboardLogMessage);
304+
var content = logLine.Content;
305+
if (TimestampParser.TryParseConsoleTimestamp(content, out var result))
306+
{
307+
content = result.Value.ModifiedText;
308+
}
309+
310+
logMessage = JsonSerializer.Deserialize(content, DashboardLogMessageContext.Default.DashboardLogMessage);
304311
}
305312
catch (JsonException)
306313
{
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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 System.Diagnostics.CodeAnalysis;
5+
using System.Globalization;
6+
using System.Text.RegularExpressions;
7+
8+
namespace Aspire.Dashboard.ConsoleLogs;
9+
10+
internal static partial class TimestampParser
11+
{
12+
private static readonly Regex s_rfc3339RegEx = GenerateRfc3339RegEx();
13+
14+
public static bool TryParseConsoleTimestamp(string text, [NotNullWhen(true)] out TimestampParserResult? result)
15+
{
16+
var match = s_rfc3339RegEx.Match(text);
17+
18+
if (match.Success)
19+
{
20+
var span = text.AsSpan();
21+
var timestamp = span[match.Index..(match.Index + match.Length)];
22+
23+
ReadOnlySpan<char> content;
24+
if (match.Index + match.Length >= span.Length)
25+
{
26+
content = "";
27+
}
28+
else
29+
{
30+
content = span[(match.Index + match.Length)..];
31+
32+
// Trim whitespace added by logging between timestamp and content.
33+
if (char.IsWhiteSpace(content[0]))
34+
{
35+
content = content.Slice(1);
36+
}
37+
}
38+
39+
result = new(content.ToString(), DateTimeOffset.Parse(timestamp.ToString(), CultureInfo.InvariantCulture));
40+
return true;
41+
}
42+
43+
result = default;
44+
return false;
45+
}
46+
47+
// Regular Expression for an RFC3339 timestamp, including RFC3339Nano
48+
//
49+
// Example timestamps:
50+
// 2023-10-02T12:56:35.123456789Z
51+
// 2023-10-02T13:56:35.123456789+10:00
52+
// 2023-10-02T13:56:35.123456789-10:00
53+
// 2023-10-02T13:56:35.123456789Z10:00
54+
// 2023-10-02T13:56:35.123456Z
55+
// 2023-10-02T13:56:35Z
56+
//
57+
// Explanation:
58+
// ^ - Starts the string
59+
// (?:\\d{4}) - Four digits for the year
60+
// - - Separator for the date
61+
// (?:0[1-9]|1[0-2]) - Two digits for the month, restricted to 01-12
62+
// - - Separator for the date
63+
// (?:0[1-9]|[12][0-9]|3[01]) - Two digits for the day, restricted to 01-31
64+
// [T ] - Literal, separator between date and time, either a T or a space
65+
// (?:[01][0-9]|2[0-3]) - Two digits for the hour, restricted to 00-23
66+
// : - Separator for the time
67+
// (?:[0-5][0-9]) - Two digits for the minutes, restricted to 00-59
68+
// : - Separator for the time
69+
// (?:[0-5][0-9]) - Two digits for the seconds, restricted to 00-59
70+
// (?:\\.\\d{1,9}) - A period and up to nine digits for the partial seconds
71+
// Z - Literal, same as +00:00
72+
// (?:[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
73+
//
74+
// 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
75+
[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])))?")]
76+
private static partial Regex GenerateRfc3339RegEx();
77+
78+
public readonly record struct TimestampParserResult(string ModifiedText, DateTimeOffset Timestamp);
79+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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 System.Globalization;
5+
using System.Text.Json;
6+
using System.Threading.Channels;
7+
using Aspire.Hosting.Dashboard;
8+
using Aspire.Hosting.Dcp;
9+
using Microsoft.Extensions.Configuration;
10+
using Microsoft.Extensions.Hosting;
11+
using Microsoft.Extensions.Logging;
12+
using Microsoft.Extensions.Logging.Abstractions;
13+
using Microsoft.Extensions.Logging.Testing;
14+
using Microsoft.Extensions.Options;
15+
using Xunit;
16+
17+
namespace Aspire.Hosting.Tests.Dashboard;
18+
19+
public class DashboardLifecycleHookTests
20+
{
21+
[Theory]
22+
[MemberData(nameof(Data))]
23+
public async Task WatchDashboardLogs_WrittenToHostLoggerFactory(string logMessage, string expectedMessage, string expectedCategory, LogLevel expectedLevel)
24+
{
25+
// Arrange
26+
var testSink = new TestSink();
27+
var factory = LoggerFactory.Create(b =>
28+
{
29+
b.SetMinimumLevel(LogLevel.Trace);
30+
b.AddProvider(new TestLoggerProvider(testSink));
31+
});
32+
var logChannel = Channel.CreateUnbounded<WriteContext>();
33+
testSink.MessageLogged += c => logChannel.Writer.TryWrite(c);
34+
35+
var resourceLoggerService = new ResourceLoggerService();
36+
var resourceNotificationService = new ResourceNotificationService(NullLogger<ResourceNotificationService>.Instance, new TestHostApplicationLifetime());
37+
var configuration = new ConfigurationBuilder().Build();
38+
var hook = new DashboardLifecycleHook(
39+
configuration,
40+
Options.Create(new DashboardOptions { DashboardPath = "test.dll" }),
41+
NullLogger<DistributedApplication>.Instance,
42+
new TestDashboardEndpointProvider(),
43+
new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
44+
resourceNotificationService,
45+
resourceLoggerService,
46+
factory);
47+
48+
var model = new DistributedApplicationModel(new ResourceCollection());
49+
await hook.BeforeStartAsync(model, CancellationToken.None);
50+
51+
await resourceNotificationService.PublishUpdateAsync(model.Resources.Single(), s => s);
52+
53+
await foreach (var item in resourceLoggerService.WatchAnySubscribersAsync())
54+
{
55+
if (item.Name == KnownResourceNames.AspireDashboard && item.AnySubscribers)
56+
{
57+
break;
58+
}
59+
}
60+
61+
// Act
62+
var dashboardLogger = resourceLoggerService.GetLogger(KnownResourceNames.AspireDashboard);
63+
dashboardLogger.LogError(logMessage);
64+
65+
// Assert
66+
var logContext = await logChannel.Reader.ReadAsync();
67+
Assert.Equal(expectedCategory, logContext.LoggerName);
68+
Assert.Equal(expectedMessage, logContext.Message);
69+
Assert.Equal(expectedLevel, logContext.LogLevel);
70+
}
71+
72+
public static IEnumerable<object[]> Data()
73+
{
74+
var timestamp = new DateTime(2001, 12, 29, 23, 59, 59, DateTimeKind.Utc);
75+
var message = new DashboardLogMessage
76+
{
77+
LogLevel = LogLevel.Error,
78+
Category = "TestCategory",
79+
Message = "Hello world",
80+
Timestamp = timestamp.ToString(KnownFormats.ConsoleLogsTimestampFormat, CultureInfo.InvariantCulture),
81+
};
82+
var messageJson = JsonSerializer.Serialize(message, DashboardLogMessageContext.Default.DashboardLogMessage);
83+
84+
yield return new object[]
85+
{
86+
$"{DateTime.UtcNow.ToString(KnownFormats.ConsoleLogsTimestampFormat, CultureInfo.InvariantCulture)} {messageJson}",
87+
"Hello world",
88+
"Aspire.Hosting.Dashboard.TestCategory",
89+
LogLevel.Error
90+
};
91+
yield return new object[]
92+
{
93+
$"{DateTime.UtcNow.ToString(KnownFormats.ConsoleLogsTimestampFormat, CultureInfo.InvariantCulture)}{messageJson}",
94+
"Hello world",
95+
"Aspire.Hosting.Dashboard.TestCategory",
96+
LogLevel.Error
97+
};
98+
yield return new object[]
99+
{
100+
messageJson,
101+
"Hello world",
102+
"Aspire.Hosting.Dashboard.TestCategory",
103+
LogLevel.Error
104+
};
105+
106+
message = new DashboardLogMessage
107+
{
108+
LogLevel = LogLevel.Critical,
109+
Category = "TestCategory.TestSubCategory",
110+
Message = "Error message",
111+
Exception = new InvalidOperationException("Error!").ToString(),
112+
Timestamp = timestamp.ToString(KnownFormats.ConsoleLogsTimestampFormat, CultureInfo.InvariantCulture),
113+
};
114+
messageJson = JsonSerializer.Serialize(message, DashboardLogMessageContext.Default.DashboardLogMessage);
115+
116+
yield return new object[]
117+
{
118+
messageJson,
119+
$"Error message{Environment.NewLine}System.InvalidOperationException: Error!",
120+
"Aspire.Hosting.Dashboard.TestCategory.TestSubCategory",
121+
LogLevel.Critical
122+
};
123+
}
124+
125+
private sealed class TestDashboardEndpointProvider : IDashboardEndpointProvider
126+
{
127+
public Task<string> GetResourceServiceUriAsync(CancellationToken cancellationToken = default)
128+
{
129+
throw new NotImplementedException();
130+
}
131+
}
132+
133+
private sealed class TestHostApplicationLifetime : IHostApplicationLifetime
134+
{
135+
public CancellationToken ApplicationStarted { get; }
136+
public CancellationToken ApplicationStopped { get; }
137+
public CancellationToken ApplicationStopping { get; }
138+
139+
public void StopApplication()
140+
{
141+
}
142+
}
143+
}

0 commit comments

Comments
 (0)