Skip to content

Commit 00fe019

Browse files
feat: add semantic logging support for Akka.NET 1.5.56+ (#242)
* feat: add semantic logging support for Akka.NET 1.5.56+ Enhances NLogLogger to leverage Akka.NET's new semantic logging APIs introduced in version 1.5.56. This change enables structured properties to be accessible in NLog's LogEventInfo.Properties dictionary. Changes: - Modified NLogLogger.LogEvent() to extract structured properties using TryGetProperties() and populate LogEventInfo.Properties dictionary - NLog layouts and targets can now access structured properties by name using ${event-properties:PropertyName} syntax - Added comprehensive test suite (SemanticLoggingSpecs.cs) with 8 tests covering: * Named and positional template properties * Multiple properties handling * Complex object properties * Akka metadata preservation * Format specifiers handling * ${all-event-properties} support All existing tests pass (8 tests). Backwards compatible through TryGetProperties() conditional check. Example NLog layout usage: ${event-properties:UserId}|${event-properties:Email} Depends on: Akka.NET >= 1.5.56 * build: update Akka.NET version to 1.5.56 and add FluentAssertions - Updated Akka.NET dependency from 1.5.46 to 1.5.56 - Added FluentAssertions package for semantic logging tests - Required for semantic logging API support * Bump AkkaVersion to 1.5.57-Beta1 * use xunit.runner.json file * Output logs to ITestOutputHelper --------- Co-authored-by: Gregorius Soedharmo <[email protected]>
1 parent 30c2fc8 commit 00fe019

File tree

7 files changed

+285
-2
lines changed

7 files changed

+285
-2
lines changed

Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project>
22
<PropertyGroup>
33
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
4-
<AkkaVersion>1.5.46</AkkaVersion>
4+
<AkkaVersion>1.5.57-Beta2</AkkaVersion>
55
</PropertyGroup>
66
<!-- App dependencies -->
77
<ItemGroup>

src/Akka.Logger.NLog.Tests/Akka.Logger.NLog.Tests.csproj

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,18 @@
1111
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1212
</PackageReference>
1313
<PackageReference Include="Akka.TestKit.Xunit2" />
14+
<PackageReference Include="FluentAssertions" />
1415
</ItemGroup>
1516

1617
<ItemGroup>
17-
<PackageReference Include="Akka" VersionOverride="1.5.46" />
18+
<PackageReference Include="Akka" />
1819
<ProjectReference Include="..\Akka.Logger.NLog\Akka.Logger.NLog.csproj" />
1920
</ItemGroup>
2021

22+
<ItemGroup>
23+
<None Update="xunit.runner.json">
24+
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
25+
</None>
26+
</ItemGroup>
27+
2128
</Project>

src/Akka.Logger.NLog.Tests/NLogFormattingSpecs.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using FluentAssertions;
99
using FluentAssertions.Extensions;
1010
using NLog;
11+
using NLog.Config;
1112
using NLog.Targets;
1213
using Xunit;
1314
using Xunit.Abstractions;
@@ -25,6 +26,11 @@ public class NLogFormattingSpecs : TestKit.Xunit2.TestKit
2526

2627
public NLogFormattingSpecs(ITestOutputHelper helper) : base(Config, output: helper)
2728
{
29+
var target = new TestOutputTarget(helper);
30+
var config = new LoggingConfiguration();
31+
config.AddRuleForAllLevels(target);
32+
LogManager.Configuration = config;
33+
2834
Config myConfig = @"akka.loglevel = DEBUG
2935
akka.loggers=[""Akka.Logger.NLog.NLogLogger, Akka.Logger.NLog""]";
3036

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text.RegularExpressions;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Akka.Actor;
8+
using Akka.Configuration;
9+
using Akka.Event;
10+
using FluentAssertions;
11+
using FluentAssertions.Extensions;
12+
using NLog;
13+
using NLog.Config;
14+
using NLog.Targets;
15+
using Xunit;
16+
using Xunit.Abstractions;
17+
using Xunit.Sdk;
18+
using LogLevel = Akka.Event.LogLevel;
19+
20+
namespace Akka.Logger.NLog.Tests
21+
{
22+
/// <summary>
23+
/// Tests for semantic logging functionality added in Akka.NET 1.5.56.
24+
/// Verifies that structured properties from log message templates are
25+
/// accessible in NLog's LogEventInfo.Properties dictionary.
26+
/// </summary>
27+
public class SemanticLoggingSpecs: IAsyncLifetime
28+
{
29+
private static readonly Config Config = @"akka.loglevel = DEBUG
30+
akka.loggers=[""Akka.Logger.NLog.NLogLogger, Akka.Logger.NLog""]";
31+
32+
private ActorSystem _sys;
33+
private ILoggingAdapter _loggingAdapter;
34+
35+
public Task InitializeAsync()
36+
{
37+
_sys = ActorSystem.Create("semantic-test-system", Config);
38+
_loggingAdapter = Logging.GetLogger(_sys.EventStream, _sys.Name);
39+
return Task.CompletedTask;
40+
}
41+
42+
public async Task DisposeAsync()
43+
{
44+
await _sys.Terminate();
45+
}
46+
47+
[Fact(DisplayName = "Should extract named template properties and add to NLog LogEventInfo.Properties")]
48+
public void NamedTemplatePropertiesTest()
49+
{
50+
var loggingTarget = new MemoryTarget
51+
{
52+
Layout = "${message}|UserId=${event-properties:UserId}|Email=${event-properties:Email}"
53+
};
54+
LogManager.Setup().LoadConfiguration(c => c.ForLogger().WriteTo(loggingTarget));
55+
56+
_loggingAdapter.Info("User {UserId} with email {Email} logged in", 12345, "[email protected]");
57+
58+
Thread.Sleep(500); // Give logger time to process
59+
60+
var logs = loggingTarget.Logs.ToArray();
61+
logs.Should().NotBeEmpty();
62+
63+
if (logs.Any(log => log.Contains("UserId=12345") && log.Contains("[email protected]")))
64+
return;
65+
66+
throw FailException.ForFailure($"Expected log not found. Logs:\n{string.Join('\n', logs)}");
67+
}
68+
69+
[Fact(DisplayName = "Should extract positional template properties and add to NLog LogEventInfo.Properties")]
70+
public void PositionalTemplatePropertiesTest()
71+
{
72+
var loggingTarget = new MemoryTarget
73+
{
74+
Layout = "${message}|Param0=${event-properties:0}|Param1=${event-properties:1}"
75+
};
76+
LogManager.Setup().LoadConfiguration(c => c.ForLogger().WriteTo(loggingTarget));
77+
78+
_loggingAdapter.Info("User {0} logged in from {1}", "Bob", "192.168.1.1");
79+
80+
Thread.Sleep(500);
81+
82+
var logs = loggingTarget.Logs.ToArray();
83+
logs.Should().NotBeEmpty();
84+
85+
if (logs.Any(log => log.Contains("Param0=Bob") && log.Contains("Param1=192.168.1.1")))
86+
return;
87+
88+
throw FailException.ForFailure($"Expected log not found. Logs:\n{string.Join('\n', logs)}");
89+
}
90+
91+
[Fact(DisplayName = "Should handle multiple named properties in template")]
92+
public void MultipleNamedPropertiesTest()
93+
{
94+
var loggingTarget = new MemoryTarget
95+
{
96+
Layout = "${event-properties:OrderId}|${event-properties:CustomerId}|${event-properties:Amount}|${event-properties:Currency}"
97+
};
98+
LogManager.Setup().LoadConfiguration(c => c.ForLogger().WriteTo(loggingTarget));
99+
100+
_loggingAdapter.Info("Order {OrderId} for customer {CustomerId}: {Amount} {Currency}",
101+
"ORD-001", "CUST-456", 99.99, "USD");
102+
103+
Thread.Sleep(500);
104+
105+
var logs = loggingTarget.Logs.ToArray();
106+
logs.Should().NotBeEmpty();
107+
108+
if (logs.Any(log => log == "ORD-001|CUST-456|99.99|USD"))
109+
return;
110+
111+
throw FailException.ForFailure($"Expected log not found. Logs:\n{string.Join('\n', logs)}");
112+
}
113+
114+
[Fact(DisplayName = "Should handle complex objects as property values")]
115+
public void ComplexObjectPropertiesTest()
116+
{
117+
var loggingTarget = new MemoryTarget
118+
{
119+
Layout = "${message}|UserType=${event-properties:User:objectpath=GetType().Name}"
120+
};
121+
LogManager.Setup().LoadConfiguration(c => c.ForLogger().WriteTo(loggingTarget));
122+
123+
var user = new { Name = "Alice", Age = 30 };
124+
_loggingAdapter.Info("Processing user {User}", user);
125+
126+
Thread.Sleep(500);
127+
128+
var logs = loggingTarget.Logs.ToArray();
129+
logs.Should().NotBeEmpty();
130+
131+
if (logs.Any(log => log.Contains("Processing user")))
132+
return;
133+
134+
throw FailException.ForFailure($"Expected log not found. Logs:\n{string.Join('\n', logs)}");
135+
}
136+
137+
[Fact(DisplayName = "Should preserve Akka metadata properties alongside semantic logging properties")]
138+
public void AkkaMetadataAndSemanticPropertiesTest()
139+
{
140+
var loggingTarget = new MemoryTarget
141+
{
142+
Layout = "UserId=${event-properties:UserId}|LogSource=${event-properties:logSource}|ThreadId=${event-properties:threadId}"
143+
};
144+
LogManager.Setup().LoadConfiguration(c => c.ForLogger().WriteTo(loggingTarget));
145+
146+
_loggingAdapter.Info("User {UserId} action", 999);
147+
148+
Thread.Sleep(500);
149+
150+
var logs = loggingTarget.Logs.ToArray();
151+
logs.Should().NotBeEmpty();
152+
153+
var regex = new Regex(@"ThreadId=\d+");
154+
// Should have both semantic property (UserId) and Akka metadata (logSource, threadId)
155+
if (logs.Any(log => log.Contains("UserId=999") && log.Contains("LogSource=semantic-test-system") && regex.IsMatch(log)))
156+
return;
157+
158+
throw FailException.ForFailure($"Expected log not found. Logs:\n{string.Join('\n', logs)}");
159+
}
160+
161+
[Fact(DisplayName = "Should handle format specifiers in named templates")]
162+
public void FormatSpecifiersInTemplatesTest()
163+
{
164+
var loggingTarget = new MemoryTarget
165+
{
166+
Layout = "${event-properties:Amount}"
167+
};
168+
LogManager.Setup().LoadConfiguration(c => c.ForLogger().WriteTo(loggingTarget));
169+
170+
// Template has format specifier :N2, but property name should be "Amount" (specifier removed)
171+
_loggingAdapter.Info("Total amount: {Amount:N2}", 1234.5678);
172+
173+
Thread.Sleep(500);
174+
175+
var logs = loggingTarget.Logs.ToArray();
176+
logs.Should().NotBeEmpty();
177+
178+
if (logs.Any(log => log.Contains("1234.5678")))
179+
return;
180+
181+
throw FailException.ForFailure($"Expected log not found. Logs:\n{string.Join('\n', logs)}");
182+
}
183+
184+
[Fact(DisplayName = "Should handle empty/no properties gracefully")]
185+
public void NoPropertiesTest()
186+
{
187+
var loggingTarget = new MemoryTarget
188+
{
189+
Layout = "${message}|Props=${all-event-properties}"
190+
};
191+
LogManager.Setup().LoadConfiguration(c => c.ForLogger().WriteTo(loggingTarget));
192+
193+
_loggingAdapter.Info("No template properties here");
194+
195+
Thread.Sleep(500);
196+
197+
var logs = loggingTarget.Logs.ToArray();
198+
logs.Should().NotBeEmpty();
199+
200+
// Should still have Akka metadata properties (logSource, actorPath, threadId)
201+
if (logs.Any(log => log.Contains("logSource=")))
202+
return;
203+
204+
throw FailException.ForFailure($"Expected log not found. Logs:\n{string.Join('\n', logs)}");
205+
}
206+
207+
[Fact(DisplayName = "Should make all properties queryable via ${all-event-properties}")]
208+
public void AllEventPropertiesTest()
209+
{
210+
var loggingTarget = new MemoryTarget
211+
{
212+
Layout = "${all-event-properties:separator=, }"
213+
};
214+
LogManager.Setup().LoadConfiguration(c => c.ForLogger().WriteTo(loggingTarget));
215+
216+
_loggingAdapter.Info("Event {EventId} at {Timestamp}", "EVT-123", DateTime.UtcNow);
217+
218+
Thread.Sleep(500);
219+
220+
var logs = loggingTarget.Logs.ToArray();
221+
logs.Should().NotBeEmpty();
222+
223+
if (logs.Any(log =>
224+
// Should contain semantic properties
225+
log.Contains("EventId=EVT-123") && log.Contains("Timestamp=")
226+
// Should contain Akka metadata
227+
&& log.Contains("logSource=") && log.Contains("threadId=")))
228+
return;
229+
230+
throw FailException.ForFailure($"Expected log not found. Logs:\n{string.Join('\n', logs)}");
231+
}
232+
}
233+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using NLog;
2+
using NLog.Targets;
3+
using Xunit.Abstractions;
4+
5+
namespace Akka.Logger.NLog.Tests;
6+
7+
public class TestOutputTarget : TargetWithLayout
8+
{
9+
private readonly ITestOutputHelper Output;
10+
11+
public TestOutputTarget(ITestOutputHelper output) {
12+
Output = output;
13+
}
14+
15+
protected override void Write(LogEventInfo logEvent) {
16+
Output.WriteLine(RenderLogEvent(Layout, logEvent));
17+
}
18+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"$schema": "https://xunit.github.io/schema/current/xunit.runner.schema.json",
3+
"longRunningTestSeconds": 60,
4+
"parallelizeAssembly": false,
5+
"parallelizeTestCollections": false
6+
}

src/Akka.Logger.NLog/NLogLogger.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,24 @@ private static void LogEvent(LogEvent logEvent, NLogLevel logLevel)
5454
LogEventInfo logEventInfo = CreateLogEventInfo(logger, logLevel, logEvent);
5555
if (logEventInfo.TimeStamp.Kind == logEvent.Timestamp.Kind)
5656
logEventInfo.TimeStamp = logEvent.Timestamp; // Timestamp of original LogEvent (instead of async Logger thread timestamp)
57+
58+
// Add Akka metadata properties
5759
logEventInfo.Properties["logSource"] = logEvent.LogSource;
5860
var actorPath = Context?.Sender?.Path?.ToString();
5961
if (!string.IsNullOrEmpty(actorPath))
6062
logEventInfo.Properties["actorPath"] = actorPath; // Same as Serilog
6163
logEventInfo.Properties["threadId"] = logEvent.Thread.ManagedThreadId; // ThreadId of the original LogEvent (instead of async Logger threadid)
64+
65+
// Add structured logging properties from semantic logging
66+
// This enables NLog layouts and targets to access structured properties by name
67+
if (logEvent.TryGetProperties(out var properties))
68+
{
69+
foreach (var prop in properties)
70+
{
71+
logEventInfo.Properties[prop.Key] = prop.Value;
72+
}
73+
}
74+
6275
logger.Log(logEventInfo);
6376
}
6477

0 commit comments

Comments
 (0)