From db94f131966445ec197e2e76f63d15d5b52172f0 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 26 Jan 2026 17:56:53 -0600 Subject: [PATCH 01/17] feat: add OpenTelemetry trace correlation for LoggerFactoryLogger Implements trace context preservation across actor mailbox boundaries by leveraging LogEvent.ActivityContext added in Akka.NET 1.5.59. Key changes: - Add AkkaLogState struct with lazy enumeration for low-allocation trace context propagation in Microsoft.Extensions.Logging state dictionaries - Add AkkaTraceContextProcessor to extract Akka trace context and set LogRecord.TraceId/SpanId/TraceFlags for OpenTelemetry exporters - Add AddAkkaTraceCorrelation() extension method for easy configuration - Update LoggerFactoryLogger to include ActivityContext in log state - Bump Akka.NET to 1.5.59, Microsoft.Extensions to 8.0.0 - Add Aspire demo project for validation Closes #700 --- Akka.Hosting.sln | 14 + Directory.Build.props | 13 +- RELEASE_NOTES.md | 9 + .../ClusterOptionsSpec.cs | 2 +- src/Akka.Cluster.Hosting.Tests/XUnitLogger.cs | 2 +- .../CoreApiSpec.ApproveCore.verified.txt | 19 + .../CoreApiSpec.ApproveTestKit.verified.txt | 3 +- .../Akka.Hosting.TestKit.Tests.csproj | 2 +- .../Akka.Hosting.TestKit.csproj | 2 +- .../Internals/XUnitLogger.cs | 2 +- .../Akka.Hosting.Tests.csproj | 2 +- .../Logging/AkkaLogStateSpecs.cs | 261 ++++++++++++++ .../Logging/AkkaTraceContextProcessorSpecs.cs | 326 ++++++++++++++++++ .../Logging/SemanticLoggingSpecs.cs | 2 +- src/Akka.Hosting.Tests/Logging/TestLogger.cs | 2 +- src/Akka.Hosting.Tests/XUnitLogger.cs | 2 +- src/Akka.Hosting/Akka.Hosting.csproj | 1 + .../AkkaOpenTelemetryExtensions.cs | 55 +++ src/Akka.Hosting/Logging/AkkaLogState.cs | 151 ++++++++ .../Logging/AkkaTraceContextProcessor.cs | 168 +++++++++ .../Logging/LoggerFactoryLogger.cs | 81 +++-- .../RemoteConfigurationSpecs.cs | 2 +- .../Akka.Hosting.OpenTelemetry.AppHost.csproj | 22 ++ .../Program.cs | 17 + .../Actors/TraceDemoActor.cs | 65 ++++ .../Akka.Hosting.OpenTelemetry.Demo.csproj | 23 ++ .../Extensions.cs | 51 +++ .../Program.cs | 60 ++++ .../TraceDemoService.cs | 101 ++++++ 29 files changed, 1422 insertions(+), 38 deletions(-) create mode 100644 src/Akka.Hosting.Tests/Logging/AkkaLogStateSpecs.cs create mode 100644 src/Akka.Hosting.Tests/Logging/AkkaTraceContextProcessorSpecs.cs create mode 100644 src/Akka.Hosting/AkkaOpenTelemetryExtensions.cs create mode 100644 src/Akka.Hosting/Logging/AkkaLogState.cs create mode 100644 src/Akka.Hosting/Logging/AkkaTraceContextProcessor.cs create mode 100644 src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Akka.Hosting.OpenTelemetry.AppHost.csproj create mode 100644 src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Program.cs create mode 100644 src/Examples/Akka.Hosting.OpenTelemetry.Demo/Actors/TraceDemoActor.cs create mode 100644 src/Examples/Akka.Hosting.OpenTelemetry.Demo/Akka.Hosting.OpenTelemetry.Demo.csproj create mode 100644 src/Examples/Akka.Hosting.OpenTelemetry.Demo/Extensions.cs create mode 100644 src/Examples/Akka.Hosting.OpenTelemetry.Demo/Program.cs create mode 100644 src/Examples/Akka.Hosting.OpenTelemetry.Demo/TraceDemoService.cs diff --git a/Akka.Hosting.sln b/Akka.Hosting.sln index e6ad9bf7..69c1f7fb 100644 --- a/Akka.Hosting.sln +++ b/Akka.Hosting.sln @@ -44,6 +44,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.Remote.Hosting.Tests", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.Hosting.LoggingDemo", "src\Examples\Akka.Hosting.LoggingDemo\Akka.Hosting.LoggingDemo.csproj", "{298D7727-FDC6-49B2-9030-CC7F0E09B0B8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.Hosting.OpenTelemetry.Demo", "src\Examples\Akka.Hosting.OpenTelemetry.Demo\Akka.Hosting.OpenTelemetry.Demo.csproj", "{6BCB9636-DAD2-4364-8A83-3D2888FE5AB2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.Hosting.OpenTelemetry.AppHost", "src\Examples\Akka.Hosting.OpenTelemetry.AppHost\Akka.Hosting.OpenTelemetry.AppHost.csproj", "{CC22F505-B648-420E-BC8A-0FCE46082986}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -110,6 +114,14 @@ Global {298D7727-FDC6-49B2-9030-CC7F0E09B0B8}.Debug|Any CPU.Build.0 = Debug|Any CPU {298D7727-FDC6-49B2-9030-CC7F0E09B0B8}.Release|Any CPU.ActiveCfg = Release|Any CPU {298D7727-FDC6-49B2-9030-CC7F0E09B0B8}.Release|Any CPU.Build.0 = Release|Any CPU + {6BCB9636-DAD2-4364-8A83-3D2888FE5AB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6BCB9636-DAD2-4364-8A83-3D2888FE5AB2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6BCB9636-DAD2-4364-8A83-3D2888FE5AB2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6BCB9636-DAD2-4364-8A83-3D2888FE5AB2}.Release|Any CPU.Build.0 = Release|Any CPU + {CC22F505-B648-420E-BC8A-0FCE46082986}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC22F505-B648-420E-BC8A-0FCE46082986}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC22F505-B648-420E-BC8A-0FCE46082986}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC22F505-B648-420E-BC8A-0FCE46082986}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -118,6 +130,8 @@ Global {5F6A7BE8-6906-46CE-BA1C-72EA11EFA33B} = {EFA970FF-6BCC-4C38-84D8-324D40F2BF03} {4F79325B-9EE7-4501-800F-7A1F8DFBCC80} = {EFA970FF-6BCC-4C38-84D8-324D40F2BF03} {298D7727-FDC6-49B2-9030-CC7F0E09B0B8} = {EFA970FF-6BCC-4C38-84D8-324D40F2BF03} + {6BCB9636-DAD2-4364-8A83-3D2888FE5AB2} = {EFA970FF-6BCC-4C38-84D8-324D40F2BF03} + {CC22F505-B648-420E-BC8A-0FCE46082986} = {EFA970FF-6BCC-4C38-84D8-324D40F2BF03} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B99E6BB8-642A-4A68-86DF-69567CBA700A} diff --git a/Directory.Build.props b/Directory.Build.props index a71eb1cb..ca8537b5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,11 +2,12 @@ Copyright © 2013-$([System.DateTime]::Now.Year) Akka.NET Team Akka.NET Team - 1.5.57 + 1.5.59 **New Features** -* [Add semantic logging support for Akka.NET 1.5.56+](https://github.com/akkadotnet/Akka.Hosting/pull/693) - enables Microsoft.Extensions.Logging to receive properly structured state dictionaries instead of pre-formatted strings. When using Akka.NET 1.5.56+, log messages now include structured properties from the semantic logging API along with Akka metadata (ActorPath, Timestamp, Thread, LogSource). Fully backwards compatible with older Akka.NET versions. +* [Add OpenTelemetry trace correlation support for LoggerFactoryLogger](https://github.com/akkadotnet/Akka.Hosting/issues/700) - enables proper trace correlation for logs emitted from actor code. Solves the problem that Activity.Current doesn't flow across actor mailbox boundaries. When using Akka.NET 1.5.59+, LogEvent.ActivityContext captures trace context at log creation time and flows it through to OpenTelemetry LogRecords via the new AkkaTraceContextProcessor. **Updates** -* [Bump Akka version from 1.5.55 to 1.5.57](https://github.com/akkadotnet/akka.net/releases/tag/1.5.57) +* [Bump Akka version from 1.5.57 to 1.5.59](https://github.com/akkadotnet/akka.net/releases/tag/1.5.59) +* Added OpenTelemetry dependency (1.9.0+) for trace correlation support akkalogo.png https://github.com/akkadotnet/Akka.Hosting @@ -28,9 +29,9 @@ 17.11.1 6.0.3 3.1.5 - 1.5.57 - [6.0.0,) - [6.0.10,) + 1.5.59 + [8.0.0,) + [8.0.5,) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e4e44058..58a2073d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,12 @@ +#### 1.5.59 January 2026 #### + +**New Features** +* [Add OpenTelemetry trace correlation support for LoggerFactoryLogger](https://github.com/akkadotnet/Akka.Hosting/issues/700) - enables proper trace correlation for logs emitted from actor code. Solves the problem that `Activity.Current` doesn't flow across actor mailbox boundaries because it uses `AsyncLocal`. When using Akka.NET 1.5.59+, `LogEvent.ActivityContext` captures trace context at log creation time and flows it through to OpenTelemetry `LogRecord`s via the new `AkkaTraceContextProcessor`. Register with `options.AddAkkaTraceCorrelation()` in your OpenTelemetry logging configuration. + +**Updates** +* [Bump Akka version from 1.5.57 to 1.5.59](https://github.com/akkadotnet/akka.net/releases/tag/1.5.59) +* Added `OpenTelemetry` package dependency (1.9.0+) for trace correlation support + #### 1.5.57 December 16th 2025 #### **New Features** diff --git a/src/Akka.Cluster.Hosting.Tests/ClusterOptionsSpec.cs b/src/Akka.Cluster.Hosting.Tests/ClusterOptionsSpec.cs index c3bf2bc4..fd4db96d 100644 --- a/src/Akka.Cluster.Hosting.Tests/ClusterOptionsSpec.cs +++ b/src/Akka.Cluster.Hosting.Tests/ClusterOptionsSpec.cs @@ -144,7 +144,7 @@ public void ClusterOptionsConfigurationTest() using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); var jsonConfig = new ConfigurationBuilder().AddJsonStream(stream).Build(); - var clusterOptions = jsonConfig.GetSection("Akka:ClusterOptions").Get(); + var clusterOptions = jsonConfig.GetSection("Akka:ClusterOptions").Get()!; clusterOptions.SplitBrainResolver = jsonConfig.GetSection("Akka:KeepMajorityOption").Get(); var builder = new AkkaConfigurationBuilder(new ServiceCollection(), "") diff --git a/src/Akka.Cluster.Hosting.Tests/XUnitLogger.cs b/src/Akka.Cluster.Hosting.Tests/XUnitLogger.cs index b2cb0f1d..e1dc4763 100644 --- a/src/Akka.Cluster.Hosting.Tests/XUnitLogger.cs +++ b/src/Akka.Cluster.Hosting.Tests/XUnitLogger.cs @@ -58,7 +58,7 @@ public bool IsEnabled(LogLevel logLevel) }; } - public IDisposable BeginScope(TState state) + public IDisposable? BeginScope(TState state) where TState : notnull { throw new NotImplementedException(); } diff --git a/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveCore.verified.txt b/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveCore.verified.txt index 9a03b9c7..6f9c57a1 100644 --- a/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveCore.verified.txt +++ b/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveCore.verified.txt @@ -99,6 +99,10 @@ namespace Akka.Hosting public static Akka.Hosting.AkkaConfigurationBuilder WithHealthCheck(this Akka.Hosting.AkkaConfigurationBuilder builder, string name, Akka.Hosting.IAkkaHealthCheck healthCheck, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, System.Collections.Generic.IEnumerable? tags = null, System.TimeSpan? timeout = default) { } public static Akka.Hosting.AkkaConfigurationBuilder WithHealthCheck(this Akka.Hosting.AkkaConfigurationBuilder builder, string name, System.Func> healthCheck, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default, System.Collections.Generic.IEnumerable? tags = null, System.TimeSpan? timeout = default) { } } + public static class AkkaOpenTelemetryExtensions + { + public static OpenTelemetry.Logs.OpenTelemetryLoggerOptions AddAkkaTraceCorrelation(this OpenTelemetry.Logs.OpenTelemetryLoggerOptions options) { } + } public class DeadLetterOptions { public DeadLetterOptions() { } @@ -251,6 +255,21 @@ namespace Akka.Hosting.HealthChecks } namespace Akka.Hosting.Logging { + public readonly struct AkkaLogState : System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable + { + public const string SpanIdKey = "Akka.SpanId"; + public const string TraceFlagsKey = "Akka.TraceFlags"; + public const string TraceIdKey = "Akka.TraceId"; + public AkkaLogState(System.Diagnostics.ActivityContext activityContext, string formattedMessage) { } + public AkkaLogState(System.Diagnostics.ActivityContext activityContext, System.Collections.Generic.IReadOnlyDictionary semanticProperties, string actorPath, System.DateTimeOffset timestamp, int threadId, string logSource, string template, string formattedMessage) { } + public System.Collections.Generic.IEnumerator> GetEnumerator() { } + public override string ToString() { } + } + public sealed class AkkaTraceContextProcessor : OpenTelemetry.BaseProcessor + { + public AkkaTraceContextProcessor() { } + public override void OnEnd(OpenTelemetry.Logs.LogRecord data) { } + } public class LoggerFactoryLogger : Akka.Actor.ActorBase, Akka.Dispatch.IRequiresMessageQueue { protected readonly Akka.Event.ILoggingAdapter InternalLogger; diff --git a/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveTestKit.verified.txt b/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveTestKit.verified.txt index f6f9cf28..db03077d 100644 --- a/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveTestKit.verified.txt +++ b/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveTestKit.verified.txt @@ -9,7 +9,8 @@ namespace Akka.Hosting.TestKit.Internals public class XUnitLogger : Microsoft.Extensions.Logging.ILogger { public XUnitLogger(string category, Xunit.Abstractions.ITestOutputHelper helper, Microsoft.Extensions.Logging.LogLevel logLevel) { } - public System.IDisposable BeginScope(TState state) { } + public System.IDisposable? BeginScope(TState state) + where TState : notnull { } public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) { } public void Log(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, TState state, System.Exception? exception, System.Func formatter) { } } diff --git a/src/Akka.Hosting.TestKit.Tests/Akka.Hosting.TestKit.Tests.csproj b/src/Akka.Hosting.TestKit.Tests/Akka.Hosting.TestKit.Tests.csproj index a22c86ae..d3a479da 100644 --- a/src/Akka.Hosting.TestKit.Tests/Akka.Hosting.TestKit.Tests.csproj +++ b/src/Akka.Hosting.TestKit.Tests/Akka.Hosting.TestKit.Tests.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj b/src/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj index df752999..b697283c 100644 --- a/src/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj +++ b/src/Akka.Hosting.TestKit/Akka.Hosting.TestKit.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/Akka.Hosting.TestKit/Internals/XUnitLogger.cs b/src/Akka.Hosting.TestKit/Internals/XUnitLogger.cs index eb04b2e4..8dc90a7d 100644 --- a/src/Akka.Hosting.TestKit/Internals/XUnitLogger.cs +++ b/src/Akka.Hosting.TestKit/Internals/XUnitLogger.cs @@ -68,7 +68,7 @@ public bool IsEnabled(LogLevel logLevel) }; } - public IDisposable BeginScope(TState state) + public IDisposable? BeginScope(TState state) where TState : notnull { return NullScope.Instance; } diff --git a/src/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj b/src/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj index bdd3853a..eaaf90af 100644 --- a/src/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj +++ b/src/Akka.Hosting.Tests/Akka.Hosting.Tests.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/Akka.Hosting.Tests/Logging/AkkaLogStateSpecs.cs b/src/Akka.Hosting.Tests/Logging/AkkaLogStateSpecs.cs new file mode 100644 index 00000000..82698992 --- /dev/null +++ b/src/Akka.Hosting.Tests/Logging/AkkaLogStateSpecs.cs @@ -0,0 +1,261 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2024 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Akka.Hosting.Logging; +using FluentAssertions; +using Xunit; + +namespace Akka.Hosting.Tests.Logging; + +public class AkkaLogStateSpecs +{ + [Fact] + public void AkkaLogState_WithSemanticProperties_ShouldYieldAllProperties() + { + // Arrange + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + var activityContext = new ActivityContext(traceId, spanId, ActivityTraceFlags.Recorded); + + var semanticProperties = new Dictionary + { + { "UserId", 123 }, + { "Action", "Login" } + }; + + var actorPath = "akka://test/user/myactor"; + var timestamp = DateTimeOffset.UtcNow; + var threadId = 42; + var logSource = "MyActor"; + var template = "User {UserId} performed {Action}"; + var formattedMessage = "User 123 performed Login"; + + // Act + var state = new AkkaLogState( + activityContext, + semanticProperties, + actorPath, + timestamp, + threadId, + logSource, + template, + formattedMessage); + + var items = state.ToList(); + + // Assert + // Should have: 3 trace context + 2 semantic props + 4 Akka metadata + 1 OriginalFormat = 10 + items.Should().HaveCount(10); + + // Verify trace context (should be ActivityTraceId/ActivitySpanId, not strings) + items.Should().Contain(kvp => kvp.Key == AkkaLogState.TraceIdKey && kvp.Value is ActivityTraceId); + items.Should().Contain(kvp => kvp.Key == AkkaLogState.SpanIdKey && kvp.Value is ActivitySpanId); + items.Should().Contain(kvp => kvp.Key == AkkaLogState.TraceFlagsKey && (int)kvp.Value! == (int)ActivityTraceFlags.Recorded); + + // Verify semantic properties + items.Should().Contain(kvp => kvp.Key == "UserId" && (int)kvp.Value! == 123); + items.Should().Contain(kvp => kvp.Key == "Action" && (string)kvp.Value! == "Login"); + + // Verify Akka metadata + items.Should().Contain(kvp => kvp.Key == "ActorPath" && (string)kvp.Value! == actorPath); + items.Should().Contain(kvp => kvp.Key == "Timestamp" && (DateTimeOffset)kvp.Value! == timestamp); + items.Should().Contain(kvp => kvp.Key == "Thread" && (int)kvp.Value! == threadId); + items.Should().Contain(kvp => kvp.Key == "LogSource" && (string)kvp.Value! == logSource); + + // Verify OriginalFormat + items.Should().Contain(kvp => kvp.Key == "{OriginalFormat}" && (string)kvp.Value! == template); + } + + [Fact] + public void AkkaLogState_WithoutTraceContext_ShouldOmitTraceProperties() + { + // Arrange + var activityContext = default(ActivityContext); // No trace context + + var semanticProperties = new Dictionary + { + { "Message", "Hello" } + }; + + // Act + var state = new AkkaLogState( + activityContext, + semanticProperties, + "akka://test/user/actor", + DateTimeOffset.UtcNow, + 1, + "Source", + "Template", + "Formatted"); + + var items = state.ToList(); + + // Assert + // Should have: 0 trace context + 1 semantic prop + 4 Akka metadata + 1 OriginalFormat = 6 + items.Should().HaveCount(6); + + // Should NOT contain trace context keys + items.Should().NotContain(kvp => kvp.Key == AkkaLogState.TraceIdKey); + items.Should().NotContain(kvp => kvp.Key == AkkaLogState.SpanIdKey); + items.Should().NotContain(kvp => kvp.Key == AkkaLogState.TraceFlagsKey); + } + + [Fact] + public void AkkaLogState_NonStructuredMessage_ShouldYieldMinimalProperties() + { + // Arrange + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + var activityContext = new ActivityContext(traceId, spanId, ActivityTraceFlags.None); + var message = "Plain text message"; + + // Act + var state = new AkkaLogState(activityContext, message); + var items = state.ToList(); + + // Assert + // Should have: 3 trace context + 1 OriginalFormat = 4 + items.Should().HaveCount(4); + + // Verify trace context is present + items.Should().Contain(kvp => kvp.Key == AkkaLogState.TraceIdKey); + items.Should().Contain(kvp => kvp.Key == AkkaLogState.SpanIdKey); + items.Should().Contain(kvp => kvp.Key == AkkaLogState.TraceFlagsKey); + + // Verify OriginalFormat + items.Should().Contain(kvp => kvp.Key == "{OriginalFormat}" && (string)kvp.Value! == message); + + // Should NOT contain Akka metadata (ActorPath, etc.) + items.Should().NotContain(kvp => kvp.Key == "ActorPath"); + } + + [Fact] + public void AkkaLogState_NonStructuredMessage_WithoutTraceContext_ShouldYieldOnlyOriginalFormat() + { + // Arrange + var activityContext = default(ActivityContext); + var message = "Plain text message"; + + // Act + var state = new AkkaLogState(activityContext, message); + var items = state.ToList(); + + // Assert + // Should have: 0 trace context + 1 OriginalFormat = 1 + items.Should().HaveCount(1); + items.Single().Key.Should().Be("{OriginalFormat}"); + items.Single().Value.Should().Be(message); + } + + [Fact] + public void AkkaLogState_ToString_ShouldReturnFormattedMessage() + { + // Arrange + var semanticProperties = new Dictionary { { "Name", "World" } }; + var formattedMessage = "Hello World!"; + + var state = new AkkaLogState( + default, + semanticProperties, + "path", + DateTimeOffset.UtcNow, + 1, + "source", + "Hello {Name}!", + formattedMessage); + + // Act & Assert + state.ToString().Should().Be(formattedMessage); + } + + [Fact] + public void AkkaLogState_ShouldBeEnumerableMultipleTimes() + { + // Arrange + var semanticProperties = new Dictionary { { "Key", "Value" } }; + var state = new AkkaLogState( + default, + semanticProperties, + "path", + DateTimeOffset.UtcNow, + 1, + "source", + "template", + "formatted"); + + // Act - enumerate multiple times + var firstPass = state.ToList(); + var secondPass = state.ToList(); + + // Assert + firstPass.Should().BeEquivalentTo(secondPass); + } + + [Fact] + public void AkkaLogState_TraceContext_ShouldStoreStructsDirectly() + { + // Arrange - this test verifies we're not allocating strings for TraceId/SpanId + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + var activityContext = new ActivityContext(traceId, spanId, ActivityTraceFlags.Recorded); + + var state = new AkkaLogState( + activityContext, + new Dictionary(), + "path", + DateTimeOffset.UtcNow, + 1, + "source", + "template", + "formatted"); + + // Act + var items = state.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + // Assert - values should be the actual struct types, not strings + items[AkkaLogState.TraceIdKey].Should().BeOfType(); + items[AkkaLogState.SpanIdKey].Should().BeOfType(); + items[AkkaLogState.TraceFlagsKey].Should().BeOfType(); + + // Verify the actual values match + ((ActivityTraceId)items[AkkaLogState.TraceIdKey]!).Should().Be(traceId); + ((ActivitySpanId)items[AkkaLogState.SpanIdKey]!).Should().Be(spanId); + ((int)items[AkkaLogState.TraceFlagsKey]!).Should().Be((int)ActivityTraceFlags.Recorded); + } + + [Fact] + public void AkkaLogState_EmptySemanticProperties_ShouldStillYieldAkkaMetadata() + { + // Arrange + var emptyProperties = new Dictionary(); + + var state = new AkkaLogState( + default, + emptyProperties, + "akka://test/user/actor", + DateTimeOffset.UtcNow, + 99, + "TestSource", + "template", + "formatted"); + + // Act + var items = state.ToList(); + + // Assert + // Should have: 0 trace context + 0 semantic props + 4 Akka metadata + 1 OriginalFormat = 5 + items.Should().HaveCount(5); + items.Should().Contain(kvp => kvp.Key == "ActorPath"); + items.Should().Contain(kvp => kvp.Key == "Timestamp"); + items.Should().Contain(kvp => kvp.Key == "Thread" && (int)kvp.Value! == 99); + items.Should().Contain(kvp => kvp.Key == "LogSource" && (string)kvp.Value! == "TestSource"); + items.Should().Contain(kvp => kvp.Key == "{OriginalFormat}"); + } +} diff --git a/src/Akka.Hosting.Tests/Logging/AkkaTraceContextProcessorSpecs.cs b/src/Akka.Hosting.Tests/Logging/AkkaTraceContextProcessorSpecs.cs new file mode 100644 index 00000000..ee4a4792 --- /dev/null +++ b/src/Akka.Hosting.Tests/Logging/AkkaTraceContextProcessorSpecs.cs @@ -0,0 +1,326 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2024 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using Akka.Hosting.Logging; +using FluentAssertions; +using OpenTelemetry.Logs; +using Xunit; + +namespace Akka.Hosting.Tests.Logging; + +public class AkkaTraceContextProcessorSpecs +{ + [Fact] + public void Processor_ShouldExtractTraceContext_FromActivityTraceIdAndSpanId() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + var traceFlags = ActivityTraceFlags.Recorded; + + var attributes = new List> + { + new(AkkaLogState.TraceIdKey, traceId), + new(AkkaLogState.SpanIdKey, spanId), + new(AkkaLogState.TraceFlagsKey, (int)traceFlags) + }; + + var logRecord = CreateLogRecord(attributes); + + // Act + processor.OnEnd(logRecord); + + // Assert + logRecord.TraceId.Should().Be(traceId); + logRecord.SpanId.Should().Be(spanId); + logRecord.TraceFlags.Should().Be(traceFlags); + } + + [Fact] + public void Processor_ShouldExtractTraceContext_FromStringRepresentation() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + + // Use string representation (backwards compatibility path) + var attributes = new List> + { + new(AkkaLogState.TraceIdKey, traceId.ToString()), + new(AkkaLogState.SpanIdKey, spanId.ToString()), + new(AkkaLogState.TraceFlagsKey, (int)ActivityTraceFlags.None) + }; + + var logRecord = CreateLogRecord(attributes); + + // Act + processor.OnEnd(logRecord); + + // Assert + logRecord.TraceId.Should().Be(traceId); + logRecord.SpanId.Should().Be(spanId); + } + + [Fact] + public void Processor_ShouldSkip_WhenTraceIdAlreadySet() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + var existingTraceId = ActivityTraceId.CreateRandom(); + var existingSpanId = ActivitySpanId.CreateRandom(); + var newTraceId = ActivityTraceId.CreateRandom(); + var newSpanId = ActivitySpanId.CreateRandom(); + + var attributes = new List> + { + new(AkkaLogState.TraceIdKey, newTraceId), + new(AkkaLogState.SpanIdKey, newSpanId), + new(AkkaLogState.TraceFlagsKey, (int)ActivityTraceFlags.Recorded) + }; + + var logRecord = CreateLogRecordWithExistingTrace(attributes, existingTraceId, existingSpanId); + + // Act + processor.OnEnd(logRecord); + + // Assert - should keep existing trace context + logRecord.TraceId.Should().Be(existingTraceId); + logRecord.SpanId.Should().Be(existingSpanId); + } + + [Fact] + public void Processor_ShouldSkip_WhenNoAttributes() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + var logRecord = CreateLogRecord(null); + + // Act + processor.OnEnd(logRecord); + + // Assert - should remain default + logRecord.TraceId.Should().Be(default(ActivityTraceId)); + logRecord.SpanId.Should().Be(default(ActivitySpanId)); + } + + [Fact] + public void Processor_ShouldSkip_WhenMissingTraceId() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + var spanId = ActivitySpanId.CreateRandom(); + + var attributes = new List> + { + // Missing TraceId + new(AkkaLogState.SpanIdKey, spanId), + new(AkkaLogState.TraceFlagsKey, 0) + }; + + var logRecord = CreateLogRecord(attributes); + + // Act + processor.OnEnd(logRecord); + + // Assert - should not set partial trace context + logRecord.TraceId.Should().Be(default(ActivityTraceId)); + logRecord.SpanId.Should().Be(default(ActivitySpanId)); + } + + [Fact] + public void Processor_ShouldSkip_WhenMissingSpanId() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + var traceId = ActivityTraceId.CreateRandom(); + + var attributes = new List> + { + new(AkkaLogState.TraceIdKey, traceId), + // Missing SpanId + new(AkkaLogState.TraceFlagsKey, 0) + }; + + var logRecord = CreateLogRecord(attributes); + + // Act + processor.OnEnd(logRecord); + + // Assert - should not set partial trace context + logRecord.TraceId.Should().Be(default(ActivityTraceId)); + logRecord.SpanId.Should().Be(default(ActivitySpanId)); + } + + [Fact] + public void Processor_ShouldHandle_InvalidStringTraceId() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + var spanId = ActivitySpanId.CreateRandom(); + + var attributes = new List> + { + new(AkkaLogState.TraceIdKey, "invalid-trace-id"), // Invalid format + new(AkkaLogState.SpanIdKey, spanId), + new(AkkaLogState.TraceFlagsKey, 0) + }; + + var logRecord = CreateLogRecord(attributes); + + // Act + processor.OnEnd(logRecord); + + // Assert - should not crash, should not set invalid trace context + logRecord.TraceId.Should().Be(default(ActivityTraceId)); + } + + [Fact] + public void Processor_ShouldHandle_UnexpectedValueTypes() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + + var attributes = new List> + { + new(AkkaLogState.TraceIdKey, 12345), // Wrong type (int instead of ActivityTraceId or string) + new(AkkaLogState.SpanIdKey, new object()), // Wrong type + new(AkkaLogState.TraceFlagsKey, "not-an-int") + }; + + var logRecord = CreateLogRecord(attributes); + + // Act - should not throw + var act = () => processor.OnEnd(logRecord); + + // Assert + act.Should().NotThrow(); + logRecord.TraceId.Should().Be(default(ActivityTraceId)); + } + + [Fact] + public void Processor_ShouldDefaultTraceFlags_WhenNotProvided() + { + // Arrange + var processor = new AkkaTraceContextProcessor(); + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + + var attributes = new List> + { + new(AkkaLogState.TraceIdKey, traceId), + new(AkkaLogState.SpanIdKey, spanId) + // No TraceFlags + }; + + var logRecord = CreateLogRecord(attributes); + + // Act + processor.OnEnd(logRecord); + + // Assert + logRecord.TraceId.Should().Be(traceId); + logRecord.SpanId.Should().Be(spanId); + logRecord.TraceFlags.Should().Be(ActivityTraceFlags.None); + } + + [Fact] + public void Processor_ShouldWork_WithAkkaLogState() + { + // Arrange - integration test with actual AkkaLogState + var processor = new AkkaTraceContextProcessor(); + var traceId = ActivityTraceId.CreateRandom(); + var spanId = ActivitySpanId.CreateRandom(); + var activityContext = new ActivityContext(traceId, spanId, ActivityTraceFlags.Recorded); + + var state = new AkkaLogState( + activityContext, + new Dictionary { { "Key", "Value" } }, + "akka://test/user/actor", + DateTimeOffset.UtcNow, + 1, + "TestSource", + "Template with {Key}", + "Template with Value"); + + // Convert state to attributes list (simulating what MEL does) + var attributes = new List>(); + foreach (var kvp in state) + { + attributes.Add(kvp); + } + + var logRecord = CreateLogRecord(attributes); + + // Act + processor.OnEnd(logRecord); + + // Assert + logRecord.TraceId.Should().Be(traceId); + logRecord.SpanId.Should().Be(spanId); + logRecord.TraceFlags.Should().Be(ActivityTraceFlags.Recorded); + } + + /// + /// Creates a LogRecord instance using UnsafeAccessor to call the internal constructor. + /// + [UnsafeAccessor(UnsafeAccessorKind.Constructor)] + private static extern LogRecord CreateLogRecordInstance(); + + /// + /// Sets the Attributes property on a LogRecord using UnsafeAccessor. + /// + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_Attributes")] + private static extern void SetAttributes(LogRecord record, IReadOnlyList>? value); + + /// + /// Sets the TraceId property on a LogRecord using UnsafeAccessor. + /// + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_TraceId")] + private static extern void SetTraceId(LogRecord record, ActivityTraceId value); + + /// + /// Sets the SpanId property on a LogRecord using UnsafeAccessor. + /// + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_SpanId")] + private static extern void SetSpanId(LogRecord record, ActivitySpanId value); + + /// + /// Creates a LogRecord with the specified attributes. + /// + private static LogRecord CreateLogRecord(IReadOnlyList>? attributes) + { + var logRecord = CreateLogRecordInstance(); + + if (attributes != null) + { + SetAttributes(logRecord, attributes); + } + + return logRecord; + } + + /// + /// Creates a LogRecord with existing trace context set. + /// + private static LogRecord CreateLogRecordWithExistingTrace( + IReadOnlyList>? attributes, + ActivityTraceId traceId, + ActivitySpanId spanId) + { + var logRecord = CreateLogRecord(attributes); + + SetTraceId(logRecord, traceId); + SetSpanId(logRecord, spanId); + + return logRecord; + } +} diff --git a/src/Akka.Hosting.Tests/Logging/SemanticLoggingSpecs.cs b/src/Akka.Hosting.Tests/Logging/SemanticLoggingSpecs.cs index cc240984..f4544c44 100644 --- a/src/Akka.Hosting.Tests/Logging/SemanticLoggingSpecs.cs +++ b/src/Akka.Hosting.Tests/Logging/SemanticLoggingSpecs.cs @@ -377,7 +377,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, public bool IsEnabled(LogLevel logLevel) => true; - public IDisposable BeginScope(TState state) => EmptyDisposable.Instance; + public IDisposable? BeginScope(TState state) where TState : notnull => EmptyDisposable.Instance; } public class LogEntry diff --git a/src/Akka.Hosting.Tests/Logging/TestLogger.cs b/src/Akka.Hosting.Tests/Logging/TestLogger.cs index dcf3b53d..6345e67c 100644 --- a/src/Akka.Hosting.Tests/Logging/TestLogger.cs +++ b/src/Akka.Hosting.Tests/Logging/TestLogger.cs @@ -76,7 +76,7 @@ public bool IsEnabled(LogLevel logLevel) return true; } - public IDisposable BeginScope(TState state) + public IDisposable? BeginScope(TState state) where TState : notnull { return EmptyDisposable.Instance; } diff --git a/src/Akka.Hosting.Tests/XUnitLogger.cs b/src/Akka.Hosting.Tests/XUnitLogger.cs index f7283aed..eb389143 100644 --- a/src/Akka.Hosting.Tests/XUnitLogger.cs +++ b/src/Akka.Hosting.Tests/XUnitLogger.cs @@ -58,7 +58,7 @@ public bool IsEnabled(LogLevel logLevel) }; } - public IDisposable BeginScope(TState state) + public IDisposable? BeginScope(TState state) where TState : notnull { throw new NotImplementedException(); } diff --git a/src/Akka.Hosting/Akka.Hosting.csproj b/src/Akka.Hosting/Akka.Hosting.csproj index d21640e2..af8459bd 100644 --- a/src/Akka.Hosting/Akka.Hosting.csproj +++ b/src/Akka.Hosting/Akka.Hosting.csproj @@ -12,5 +12,6 @@ + diff --git a/src/Akka.Hosting/AkkaOpenTelemetryExtensions.cs b/src/Akka.Hosting/AkkaOpenTelemetryExtensions.cs new file mode 100644 index 00000000..9563a946 --- /dev/null +++ b/src/Akka.Hosting/AkkaOpenTelemetryExtensions.cs @@ -0,0 +1,55 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2024 .NET Foundation +// +// ----------------------------------------------------------------------- + +using Akka.Hosting.Logging; +using OpenTelemetry.Logs; + +namespace Akka.Hosting +{ + /// + /// Extension methods for integrating Akka.NET logging with OpenTelemetry. + /// + public static class AkkaOpenTelemetryExtensions + { + /// + /// Adds the Akka.NET trace correlation processor to the OpenTelemetry logging pipeline. + /// + /// The OpenTelemetry logger options. + /// The options instance for chaining. + /// + /// + /// This processor extracts trace context (TraceId, SpanId, TraceFlags) from Akka.NET + /// log events and applies them to OpenTelemetry + /// instances. This enables proper trace correlation for logs emitted from actor code, + /// solving the problem that doesn't + /// flow across actor mailbox boundaries. + /// + /// + /// Important: This processor should be registered before any exporters + /// to ensure the trace context is applied before logs are exported. + /// + /// + /// + /// builder.Logging.AddOpenTelemetry(options => + /// { + /// options.SetResourceBuilder(ResourceBuilder.CreateDefault() + /// .AddService("my-service")); + /// + /// // Register Akka trace correlation FIRST (before exporters) + /// options.AddAkkaTraceCorrelation(); + /// + /// options.AddOtlpExporter(); + /// }); + /// + /// + /// + public static OpenTelemetryLoggerOptions AddAkkaTraceCorrelation(this OpenTelemetryLoggerOptions options) + { + options.AddProcessor(new AkkaTraceContextProcessor()); + return options; + } + } +} diff --git a/src/Akka.Hosting/Logging/AkkaLogState.cs b/src/Akka.Hosting/Logging/AkkaLogState.cs new file mode 100644 index 00000000..c052476d --- /dev/null +++ b/src/Akka.Hosting/Logging/AkkaLogState.cs @@ -0,0 +1,151 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2024 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Akka.Hosting.Logging +{ + /// + /// Log state struct that carries both structured log properties and OpenTelemetry trace context. + /// Implements for compatibility with Microsoft.Extensions.Logging. + /// + /// + /// + /// This struct is designed for minimal allocations - it holds references to existing data + /// and yields values lazily via the enumerator rather than copying into a new collection. + /// + /// + /// This struct is used to propagate trace context (TraceId, SpanId, TraceFlags) from the + /// originating actor thread to the logging infrastructure, solving the problem that + /// doesn't flow across actor mailbox boundaries because + /// it uses . + /// + /// + public readonly struct AkkaLogState : IEnumerable> + { + /// + /// Key for the trace ID in the log state dictionary. + /// + public const string TraceIdKey = "Akka.TraceId"; + + /// + /// Key for the span ID in the log state dictionary. + /// + public const string SpanIdKey = "Akka.SpanId"; + + /// + /// Key for the trace flags in the log state dictionary. + /// + public const string TraceFlagsKey = "Akka.TraceFlags"; + + private readonly ActivityContext _activityContext; + private readonly IReadOnlyDictionary? _semanticProperties; + private readonly string _actorPath; + private readonly DateTimeOffset _timestamp; + private readonly int _threadId; + private readonly string _logSource; + private readonly string _template; + private readonly string _formattedMessage; + private readonly bool _hasSemanticProperties; + + /// + /// Creates an with trace context, semantic properties, and Akka metadata. + /// + /// The activity context containing trace correlation IDs. + /// Structured properties from the log message template (referenced, not copied). + /// The path of the actor that generated the log. + /// The timestamp when the log was created. + /// The managed thread ID of the originating thread. + /// The source of the log event. + /// The message template string. + /// The pre-formatted message for display. + public AkkaLogState( + ActivityContext activityContext, + IReadOnlyDictionary semanticProperties, + string actorPath, + DateTimeOffset timestamp, + int threadId, + string logSource, + string template, + string formattedMessage) + { + _activityContext = activityContext; + _semanticProperties = semanticProperties; + _actorPath = actorPath; + _timestamp = timestamp; + _threadId = threadId; + _logSource = logSource; + _template = template; + _formattedMessage = formattedMessage; + _hasSemanticProperties = true; + } + + /// + /// Creates an with trace context for non-structured messages. + /// + /// The activity context containing trace correlation IDs. + /// The formatted log message. + public AkkaLogState(ActivityContext activityContext, string formattedMessage) + { + _activityContext = activityContext; + _formattedMessage = formattedMessage; + _semanticProperties = null; + _actorPath = string.Empty; + _timestamp = default; + _threadId = 0; + _logSource = string.Empty; + _template = formattedMessage; + _hasSemanticProperties = false; + } + + /// + public IEnumerator> GetEnumerator() + { + // Yield trace context if present (store structs directly, no ToString() allocation) + if (_activityContext.TraceId != default) + { + yield return new KeyValuePair(TraceIdKey, _activityContext.TraceId); + yield return new KeyValuePair(SpanIdKey, _activityContext.SpanId); + yield return new KeyValuePair(TraceFlagsKey, (int)_activityContext.TraceFlags); + } + + if (_hasSemanticProperties) + { + // Yield semantic properties (referenced, not copied) + if (_semanticProperties != null) + { + foreach (var prop in _semanticProperties) + { + yield return new KeyValuePair(prop.Key, prop.Value); + } + } + + // Yield Akka metadata + yield return new KeyValuePair("ActorPath", _actorPath); + yield return new KeyValuePair("Timestamp", _timestamp); + yield return new KeyValuePair("Thread", _threadId); + yield return new KeyValuePair("LogSource", _logSource); + + // Yield OriginalFormat for MEL convention + yield return new KeyValuePair("{OriginalFormat}", _template); + } + else + { + // For non-structured messages, just yield OriginalFormat + yield return new KeyValuePair("{OriginalFormat}", _template); + } + } + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + public override string ToString() => _formattedMessage; + } +} diff --git a/src/Akka.Hosting/Logging/AkkaTraceContextProcessor.cs b/src/Akka.Hosting/Logging/AkkaTraceContextProcessor.cs new file mode 100644 index 00000000..275f94c4 --- /dev/null +++ b/src/Akka.Hosting/Logging/AkkaTraceContextProcessor.cs @@ -0,0 +1,168 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2024 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Diagnostics; +using OpenTelemetry; +using OpenTelemetry.Logs; + +namespace Akka.Hosting.Logging +{ + /// + /// OpenTelemetry log processor that extracts trace context from Akka.NET log events + /// and applies it to the . + /// + /// + /// + /// This processor solves the problem that doesn't flow + /// across actor mailbox boundaries because it uses . + /// + /// + /// When emits logs, it includes the original + /// captured at log creation time as attributes + /// (Akka.TraceId, Akka.SpanId, Akka.TraceFlags). This processor extracts those + /// attributes and sets them on the so that the log + /// is properly correlated with the originating trace. + /// + /// + public sealed class AkkaTraceContextProcessor : BaseProcessor + { + /// + public override void OnEnd(LogRecord data) + { + // Skip if TraceId is already set (e.g., from Activity.Current that happened to be present) + if (data.TraceId != default) + { + return; + } + + // Try to extract trace context from Akka log state attributes + var attributes = data.Attributes; + if (attributes == null) + { + return; + } + + ActivityTraceId? traceId = null; + ActivitySpanId? spanId = null; + ActivityTraceFlags traceFlags = ActivityTraceFlags.None; + + foreach (var attr in attributes) + { + switch (attr.Key) + { + case AkkaLogState.TraceIdKey: + traceId = ExtractTraceId(attr.Value); + break; + + case AkkaLogState.SpanIdKey: + spanId = ExtractSpanId(attr.Value); + break; + + case AkkaLogState.TraceFlagsKey when attr.Value is int flagsInt: + traceFlags = (ActivityTraceFlags)flagsInt; + break; + } + } + + // Only set trace context if we found both TraceId and SpanId + if (traceId.HasValue && spanId.HasValue) + { + SetTraceContext(data, traceId.Value, spanId.Value, traceFlags); + } + } + + private static ActivityTraceId? ExtractTraceId(object? value) + { + // Handle ActivityTraceId directly (no allocation path) + if (value is ActivityTraceId traceId) + { + return traceId; + } + + // Fallback: handle string representation (for backwards compatibility) + if (value is string traceIdStr) + { + return TryParseTraceId(traceIdStr); + } + + return null; + } + + private static ActivitySpanId? ExtractSpanId(object? value) + { + // Handle ActivitySpanId directly (no allocation path) + if (value is ActivitySpanId spanId) + { + return spanId; + } + + // Fallback: handle string representation (for backwards compatibility) + if (value is string spanIdStr) + { + return TryParseSpanId(spanIdStr); + } + + return null; + } + + private static void SetTraceContext(LogRecord record, ActivityTraceId traceId, ActivitySpanId spanId, ActivityTraceFlags traceFlags) + { + // LogRecord has internal setters, so we need to use reflection + try + { + var recordType = typeof(LogRecord); + + var traceIdProp = recordType.GetProperty("TraceId"); + var spanIdProp = recordType.GetProperty("SpanId"); + var traceFlagsProp = recordType.GetProperty("TraceFlags"); + + traceIdProp?.SetValue(record, traceId); + spanIdProp?.SetValue(record, spanId); + traceFlagsProp?.SetValue(record, traceFlags); + } + catch + { + // Silently ignore reflection failures + // The trace context attributes are still present in the log state + } + } + + private static ActivityTraceId? TryParseTraceId(string traceIdStr) + { + try + { + // ActivityTraceId expects a 32 character hex string + if (traceIdStr.Length == 32) + { + return ActivityTraceId.CreateFromString(traceIdStr.AsSpan()); + } + } + catch + { + // Ignore parse failures + } + return null; + } + + private static ActivitySpanId? TryParseSpanId(string spanIdStr) + { + try + { + // ActivitySpanId expects a 16 character hex string + if (spanIdStr.Length == 16) + { + return ActivitySpanId.CreateFromString(spanIdStr.AsSpan()); + } + } + catch + { + // Ignore parse failures + } + return null; + } + } +} diff --git a/src/Akka.Hosting/Logging/LoggerFactoryLogger.cs b/src/Akka.Hosting/Logging/LoggerFactoryLogger.cs index b5c616c8..72101e8a 100644 --- a/src/Akka.Hosting/Logging/LoggerFactoryLogger.cs +++ b/src/Akka.Hosting/Logging/LoggerFactoryLogger.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Text; using Akka.Actor; @@ -64,40 +65,78 @@ protected virtual void Log(LogEvent log, ActorPath path) { var logLevel = GetLogLevel(log.LogLevel()); + // Try to get ActivityContext for OpenTelemetry trace correlation + // This captures trace context that was active when the log was created, + // solving the problem that Activity.Current doesn't flow across mailbox boundaries + var activityContext = TryGetActivityContext(log); + // Use semantic logging to extract structured properties if (log.TryGetProperties(out var properties) && properties is not null) { - // Create state dictionary with structured properties from the log message - var state = new Dictionary(properties.Count + 4); - - // Add structured properties from the message template - foreach (var prop in properties) - { - state[prop.Key] = prop.Value; - } - - // Add Akka metadata properties - state["ActorPath"] = path.ToString(); - state["Timestamp"] = log.Timestamp; - state["Thread"] = log.Thread.ManagedThreadId; - state["LogSource"] = log.LogSource; + var formattedMessage = FormatMessage(log.GetTemplate(), log.GetParameters().ToArray()); - // Add {OriginalFormat} key per MEL convention for structured logging - // This allows MEL sinks to recognize and preserve the message template - state["{OriginalFormat}"] = log.GetTemplate(); + // Use AkkaLogState to include trace context with structured properties + var state = new AkkaLogState( + activityContext, + properties, + path.ToString(), + log.Timestamp, + log.Thread.ManagedThreadId, + log.LogSource, + log.GetTemplate(), + formattedMessage); - // Log with structured state + // Log with structured state including trace context _akkaLogger.Log(logLevel, new EventId(), state, log.Cause, - (s, ex) => FormatMessage(log.GetTemplate(), log.GetParameters().ToArray())); + (s, ex) => s.ToString()); } else { // Fallback for non-structured messages (plain strings) - _akkaLogger.Log(logLevel, new EventId(), log, log.Cause, - (@event, exception) => @event.ToString()); + // Still include trace context if available + if (activityContext.TraceId != default) + { + var state = new AkkaLogState(activityContext, log.ToString()); + _akkaLogger.Log(logLevel, new EventId(), state, log.Cause, + (s, ex) => s.ToString()); + } + else + { + _akkaLogger.Log(logLevel, new EventId(), log, log.Cause, + (@event, exception) => @event.ToString()); + } } } + /// + /// Attempts to extract the ActivityContext from a LogEvent. + /// Uses reflection to maintain compatibility with older Akka.NET versions + /// that don't have the ActivityContext property. + /// + private static ActivityContext TryGetActivityContext(LogEvent log) + { + // Try to get ActivityContext via the property added in Akka.NET 1.5.59 + // Use reflection for backwards compatibility with older versions + try + { + var activityContextProperty = log.GetType().GetProperty("ActivityContext"); + if (activityContextProperty != null) + { + var value = activityContextProperty.GetValue(log); + if (value is ActivityContext context) + { + return context; + } + } + } + catch + { + // Ignore reflection errors - just return default + } + + return default; + } + private static string FormatMessage(string template, object[] args) { try diff --git a/src/Akka.Remote.Hosting.Tests/RemoteConfigurationSpecs.cs b/src/Akka.Remote.Hosting.Tests/RemoteConfigurationSpecs.cs index 29b2436c..0c8e6789 100644 --- a/src/Akka.Remote.Hosting.Tests/RemoteConfigurationSpecs.cs +++ b/src/Akka.Remote.Hosting.Tests/RemoteConfigurationSpecs.cs @@ -651,7 +651,7 @@ public async Task ClusterOptionsConfigurationTest() // arrange using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); var jsonConfig = new ConfigurationBuilder().AddJsonStream(stream).Build(); - var remoteOptions = jsonConfig.GetSection("Akka:RemoteOptions").Get(); + var remoteOptions = jsonConfig.GetSection("Akka:RemoteOptions").Get()!; using var host = new HostBuilder().ConfigureServices(services => { diff --git a/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Akka.Hosting.OpenTelemetry.AppHost.csproj b/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Akka.Hosting.OpenTelemetry.AppHost.csproj new file mode 100644 index 00000000..7381cd13 --- /dev/null +++ b/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Akka.Hosting.OpenTelemetry.AppHost.csproj @@ -0,0 +1,22 @@ + + + + Exe + net8.0 + enable + enable + true + false + 3f42e154-c98e-4c42-9f2b-8f8f8f8f8f8f + + + + + + + + + + + + diff --git a/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Program.cs b/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Program.cs new file mode 100644 index 00000000..40979eb7 --- /dev/null +++ b/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Program.cs @@ -0,0 +1,17 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2024 .NET Foundation +// +// ----------------------------------------------------------------------- + +var builder = DistributedApplication.CreateBuilder(args); + +// Add Seq for log collection and visualization +var seq = builder.AddSeq("seq") + .WithDataVolume(); + +// Add the Akka.NET demo service with Seq reference for OTLP export +builder.AddProject("demo") + .WithReference(seq); + +builder.Build().Run(); diff --git a/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Actors/TraceDemoActor.cs b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Actors/TraceDemoActor.cs new file mode 100644 index 00000000..9548b8a4 --- /dev/null +++ b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Actors/TraceDemoActor.cs @@ -0,0 +1,65 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2024 .NET Foundation +// +// ----------------------------------------------------------------------- + +using Akka.Actor; +using Akka.Event; + +namespace Akka.Hosting.OpenTelemetry.Demo.Actors; + +/// +/// Messages for the trace demo actor. +/// +public sealed record ProcessRequest(string RequestId, string Data); +public sealed record ProcessResponse(string RequestId, string Result); + +/// +/// Actor that demonstrates OpenTelemetry trace correlation. +/// Logs are emitted with the trace context from the originating request, +/// even though Activity.Current doesn't flow across mailbox boundaries. +/// +public sealed class TraceDemoActor : ReceiveActor +{ + private readonly ILoggingAdapter _log = Context.GetLogger(); + + public TraceDemoActor() + { + Receive(HandleProcessRequest); + } + + private void HandleProcessRequest(ProcessRequest request) + { + // This log will include the TraceId and SpanId from the originating request + // even though we're on a different thread after crossing the mailbox boundary + _log.Info("Processing request {RequestId} with data: {Data}", request.RequestId, request.Data); + + // Simulate some processing + var result = $"Processed: {request.Data.ToUpperInvariant()}"; + + _log.Info("Completed processing request {RequestId}, result: {Result}", request.RequestId, result); + + Sender.Tell(new ProcessResponse(request.RequestId, result)); + } +} + +/// +/// Actor that forwards messages to demonstrate trace context flowing through actor chains. +/// +public sealed class ForwarderActor : ReceiveActor +{ + private readonly ILoggingAdapter _log = Context.GetLogger(); + private readonly IActorRef _target; + + public ForwarderActor(IActorRef target) + { + _target = target; + + Receive(request => + { + _log.Info("Forwarding request {RequestId} to processor", request.RequestId); + _target.Forward(request); + }); + } +} diff --git a/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Akka.Hosting.OpenTelemetry.Demo.csproj b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Akka.Hosting.OpenTelemetry.Demo.csproj new file mode 100644 index 00000000..34f58cd9 --- /dev/null +++ b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Akka.Hosting.OpenTelemetry.Demo.csproj @@ -0,0 +1,23 @@ + + + + Exe + net8.0 + enable + enable + false + + + + + + + + + + + + + + + diff --git a/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Extensions.cs b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Extensions.cs new file mode 100644 index 00000000..0820d48a --- /dev/null +++ b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Extensions.cs @@ -0,0 +1,51 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2024 .NET Foundation +// +// ----------------------------------------------------------------------- + +using OpenTelemetry.Exporter; +using OpenTelemetry.Trace; + +namespace Akka.Hosting.OpenTelemetry.Demo; + +/// +/// Extension methods for configuring Aspire service defaults. +/// +public static class Extensions +{ + /// + /// Adds Aspire service defaults including OpenTelemetry and health checks. + /// + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + // Add service discovery + builder.Services.AddServiceDiscovery(); + + // Configure OpenTelemetry tracing + builder.Services.AddOpenTelemetry() + .WithTracing(tracing => + { + tracing.AddSource("Akka.Hosting.OpenTelemetry.Demo"); + }); + + // Add OTLP exporters if configured + var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; + if (!string.IsNullOrWhiteSpace(otlpEndpoint)) + { + builder.Services.AddOpenTelemetry() + .WithTracing(tracing => + { + tracing.AddOtlpExporter(options => + { + options.Endpoint = new Uri(otlpEndpoint); + }); + }); + } + + // Add health checks + builder.Services.AddHealthChecks(); + + return builder; + } +} diff --git a/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Program.cs b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Program.cs new file mode 100644 index 00000000..08ed9969 --- /dev/null +++ b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Program.cs @@ -0,0 +1,60 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2024 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System.Diagnostics; +using Akka.Actor; +using Akka.Hosting; +using Akka.Hosting.Logging; +using Akka.Hosting.OpenTelemetry.Demo; +using Akka.Hosting.OpenTelemetry.Demo.Actors; +using OpenTelemetry.Logs; +using OpenTelemetry.Resources; + +var builder = Host.CreateApplicationBuilder(args); + +// Add Aspire service defaults (includes Seq integration via OTLP) +builder.AddServiceDefaults(); + +// Configure OpenTelemetry logging with Akka trace correlation +builder.Logging.AddOpenTelemetry(options => +{ + options.SetResourceBuilder(ResourceBuilder.CreateDefault() + .AddService("akka-otel-demo")); + + // CRITICAL: Register Akka trace correlation processor FIRST (before exporters) + // This extracts trace context from Akka log attributes and applies it to LogRecords + options.AddAkkaTraceCorrelation(); + + // Include formatted message for easier debugging + options.IncludeFormattedMessage = true; + options.IncludeScopes = true; +}); + +// Configure Akka.NET with LoggerFactoryLogger +builder.Services.AddAkka("TraceDemo", configBuilder => +{ + configBuilder.ConfigureLoggers(setup => + { + setup.ClearLoggers(); + setup.AddLoggerFactory(); + }); + + // Register actors + configBuilder.WithActors((system, registry, resolver) => + { + var processor = system.ActorOf(Props.Create(), "processor"); + var forwarder = system.ActorOf(Props.Create(() => new ForwarderActor(processor)), "forwarder"); + + registry.Register(processor); + registry.Register(forwarder); + }); +}); + +// Add background service to demonstrate trace correlation +builder.Services.AddHostedService(); + +var app = builder.Build(); +app.Run(); diff --git a/src/Examples/Akka.Hosting.OpenTelemetry.Demo/TraceDemoService.cs b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/TraceDemoService.cs new file mode 100644 index 00000000..d9da9626 --- /dev/null +++ b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/TraceDemoService.cs @@ -0,0 +1,101 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2024 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System.Diagnostics; +using Akka.Actor; +using Akka.Hosting; +using Akka.Hosting.OpenTelemetry.Demo.Actors; + +namespace Akka.Hosting.OpenTelemetry.Demo; + +/// +/// Background service that demonstrates OpenTelemetry trace correlation with Akka.NET actors. +/// Creates traced operations and sends messages to actors, verifying that logs from actors +/// are correlated with the originating trace. +/// +public sealed class TraceDemoService : BackgroundService +{ + private static readonly ActivitySource ActivitySource = new("Akka.Hosting.OpenTelemetry.Demo"); + + private readonly ILogger _logger; + private readonly IRequiredActor _processorActor; + private readonly IRequiredActor _forwarderActor; + private int _requestCounter; + + public TraceDemoService( + ILogger logger, + IRequiredActor processorActor, + IRequiredActor forwarderActor) + { + _logger = logger; + _processorActor = processorActor; + _forwarderActor = forwarderActor; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Wait a moment for the actor system to fully initialize + await Task.Delay(2000, stoppingToken); + + _logger.LogInformation("Starting trace correlation demo..."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + // Create a traced operation + using var activity = ActivitySource.StartActivity("ProcessMessage"); + + var requestId = $"REQ-{++_requestCounter:D4}"; + var data = $"Sample data for request {requestId}"; + + _logger.LogInformation("Sending request {RequestId} to actor within trace {TraceId}", + requestId, activity?.TraceId.ToString() ?? "none"); + + // Send directly to processor + var processor = await _processorActor.GetAsync(stoppingToken); + var response = await processor.Ask( + new ProcessRequest(requestId, data), + TimeSpan.FromSeconds(5), + stoppingToken); + + _logger.LogInformation("Received response for {RequestId}: {Result}", + response.RequestId, response.Result); + + // Now test forwarding through multiple actors + using var forwardActivity = ActivitySource.StartActivity("ForwardMessage"); + + var forwardRequestId = $"FWD-{_requestCounter:D4}"; + var forwardData = $"Forwarded data for request {forwardRequestId}"; + + _logger.LogInformation("Sending forwarded request {RequestId} within trace {TraceId}", + forwardRequestId, forwardActivity?.TraceId.ToString() ?? "none"); + + var forwarder = await _forwarderActor.GetAsync(stoppingToken); + var forwardResponse = await forwarder.Ask( + new ProcessRequest(forwardRequestId, forwardData), + TimeSpan.FromSeconds(5), + stoppingToken); + + _logger.LogInformation("Received forwarded response for {RequestId}: {Result}", + forwardResponse.RequestId, forwardResponse.Result); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during trace demo iteration"); + } + + // Wait before next iteration + await Task.Delay(5000, stoppingToken); + } + + _logger.LogInformation("Trace correlation demo stopped."); + } +} From dfb0a07e344254a414f5684df0ea649f2dd3db0a Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 29 Jan 2026 08:56:05 -0600 Subject: [PATCH 02/17] chore: update Aspire deps and remove workload install --- .github/workflows/pr_validation.yml | 2 +- .../Akka.Hosting.OpenTelemetry.AppHost.csproj | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml index cb9d20e8..c67efdbb 100644 --- a/.github/workflows/pr_validation.yml +++ b/.github/workflows/pr_validation.yml @@ -47,7 +47,7 @@ jobs: - name: "dotnet test" shell: bash - run: dotnet test -c Release + run: dotnet test -c Release - name: "dotnet pack" run: dotnet pack -c Release -o ./bin/nuget diff --git a/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Akka.Hosting.OpenTelemetry.AppHost.csproj b/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Akka.Hosting.OpenTelemetry.AppHost.csproj index 7381cd13..a354c276 100644 --- a/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Akka.Hosting.OpenTelemetry.AppHost.csproj +++ b/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Akka.Hosting.OpenTelemetry.AppHost.csproj @@ -11,8 +11,8 @@ - - + + From 4c7966e212b21a4a879fd7446afbf2984945aafa Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 29 Jan 2026 09:02:07 -0600 Subject: [PATCH 03/17] chore: align Aspire AppHost sdk usage --- .../Akka.Hosting.OpenTelemetry.AppHost.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Akka.Hosting.OpenTelemetry.AppHost.csproj b/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Akka.Hosting.OpenTelemetry.AppHost.csproj index a354c276..ef2daaa8 100644 --- a/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Akka.Hosting.OpenTelemetry.AppHost.csproj +++ b/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Akka.Hosting.OpenTelemetry.AppHost.csproj @@ -1,11 +1,12 @@ + + Exe net8.0 enable enable - true false 3f42e154-c98e-4c42-9f2b-8f8f8f8f8f8f From 20e5ba0ee8065c52bb9f1459ed9c6f0c6ccaebdd Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 29 Jan 2026 09:04:54 -0600 Subject: [PATCH 04/17] fix: handle nullable ActivityContext --- src/Akka.Hosting/Logging/LoggerFactoryLogger.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Akka.Hosting/Logging/LoggerFactoryLogger.cs b/src/Akka.Hosting/Logging/LoggerFactoryLogger.cs index 1703d395..4debd06f 100644 --- a/src/Akka.Hosting/Logging/LoggerFactoryLogger.cs +++ b/src/Akka.Hosting/Logging/LoggerFactoryLogger.cs @@ -65,7 +65,7 @@ protected virtual void Log(LogEvent log, ActorPath path) var logLevel = GetLogLevel(log.LogLevel()); // Capture ActivityContext (Akka.NET 1.5.59+) for trace correlation - var activityContext = log.ActivityContext; + var activityContext = log.ActivityContext ?? default; // Use semantic logging to extract structured properties if (log.TryGetProperties(out var properties) && properties is not null) @@ -90,7 +90,7 @@ protected virtual void Log(LogEvent log, ActorPath path) { var formattedMessage = SafeFormat(log); - if (activityContext.TraceId != default) + if (log.ActivityContext.HasValue) { // Preserve trace context even for non-structured logs var state = new AkkaLogState(activityContext, formattedMessage); From 84877df40a923705383a12e315f39afecca659d6 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 29 Jan 2026 09:07:48 -0600 Subject: [PATCH 05/17] fix: align BeginScope nullability in test sink --- .../Logging/Issue701SemanticLoggingRegressionSpecs.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs b/src/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs index f2922056..93a3bc6d 100644 --- a/src/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs +++ b/src/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs @@ -277,7 +277,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, public bool IsEnabled(LogLevel logLevel) => true; - public IDisposable BeginScope(TState state) => EmptyDisposable.Instance; + public IDisposable BeginScope(TState state) where TState : notnull => EmptyDisposable.Instance; } public class BugReproLogEntry From 99fe50d2e0b379c5d0fc6521ce8fc59a168032d4 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 29 Jan 2026 09:09:42 -0600 Subject: [PATCH 06/17] test: update timestamp assertion to DateTimeOffset --- .../Logging/Issue701SemanticLoggingRegressionSpecs.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs b/src/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs index 93a3bc6d..ebdc829a 100644 --- a/src/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs +++ b/src/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs @@ -61,7 +61,7 @@ private void AssertMetadata(BugReproLogEntry entry, string? format = null) entry.State.Should().ContainKey("{OriginalFormat}"); entry.State["ActorPath"].Should().BeOfType(); - entry.State["Timestamp"].Should().BeOfType(); + entry.State["Timestamp"].Should().BeOfType(); entry.State["Thread"].Should().BeOfType(); entry.State["LogSource"].Should().BeOfType(); entry.State["{OriginalFormat}"].Should().BeOfType(); From 8f9917caafae1ea5d8009c61b888d2f7ab6cf223 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 29 Jan 2026 09:30:14 -0600 Subject: [PATCH 07/17] fix: store Akka log timestamp as DateTime --- .../verify/CoreApiSpec.ApproveCore.verified.txt | 4 ++-- .../Logging/AkkaLogStateSpecs.cs | 14 +++++++------- .../Logging/AkkaTraceContextProcessorSpecs.cs | 2 +- .../Issue701SemanticLoggingRegressionSpecs.cs | 2 +- src/Akka.Hosting/Logging/AkkaLogState.cs | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveCore.verified.txt b/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveCore.verified.txt index 641a9390..297e1e8e 100644 --- a/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveCore.verified.txt +++ b/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveCore.verified.txt @@ -261,7 +261,7 @@ namespace Akka.Hosting.Logging public const string TraceFlagsKey = "Akka.TraceFlags"; public const string TraceIdKey = "Akka.TraceId"; public AkkaLogState(System.Diagnostics.ActivityContext activityContext, string formattedMessage) { } - public AkkaLogState(System.Diagnostics.ActivityContext activityContext, System.Collections.Generic.IReadOnlyDictionary semanticProperties, string actorPath, System.DateTimeOffset timestamp, int threadId, string logSource, string template, string formattedMessage) { } + public AkkaLogState(System.Diagnostics.ActivityContext activityContext, System.Collections.Generic.IReadOnlyDictionary semanticProperties, string actorPath, System.DateTime timestamp, int threadId, string logSource, string template, string formattedMessage) { } public System.Collections.Generic.IEnumerator> GetEnumerator() { } public override string ToString() { } } @@ -283,4 +283,4 @@ namespace Akka.Hosting.Logging public LoggerFactorySetup(Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) { } public Microsoft.Extensions.Logging.ILoggerFactory LoggerFactory { get; } } -} \ No newline at end of file +} diff --git a/src/Akka.Hosting.Tests/Logging/AkkaLogStateSpecs.cs b/src/Akka.Hosting.Tests/Logging/AkkaLogStateSpecs.cs index 82698992..21ce314d 100644 --- a/src/Akka.Hosting.Tests/Logging/AkkaLogStateSpecs.cs +++ b/src/Akka.Hosting.Tests/Logging/AkkaLogStateSpecs.cs @@ -31,7 +31,7 @@ public void AkkaLogState_WithSemanticProperties_ShouldYieldAllProperties() }; var actorPath = "akka://test/user/myactor"; - var timestamp = DateTimeOffset.UtcNow; + var timestamp = DateTime.UtcNow; var threadId = 42; var logSource = "MyActor"; var template = "User {UserId} performed {Action}"; @@ -65,7 +65,7 @@ public void AkkaLogState_WithSemanticProperties_ShouldYieldAllProperties() // Verify Akka metadata items.Should().Contain(kvp => kvp.Key == "ActorPath" && (string)kvp.Value! == actorPath); - items.Should().Contain(kvp => kvp.Key == "Timestamp" && (DateTimeOffset)kvp.Value! == timestamp); + items.Should().Contain(kvp => kvp.Key == "Timestamp" && (DateTime)kvp.Value! == timestamp); items.Should().Contain(kvp => kvp.Key == "Thread" && (int)kvp.Value! == threadId); items.Should().Contain(kvp => kvp.Key == "LogSource" && (string)kvp.Value! == logSource); @@ -89,7 +89,7 @@ public void AkkaLogState_WithoutTraceContext_ShouldOmitTraceProperties() activityContext, semanticProperties, "akka://test/user/actor", - DateTimeOffset.UtcNow, + DateTime.UtcNow, 1, "Source", "Template", @@ -165,7 +165,7 @@ public void AkkaLogState_ToString_ShouldReturnFormattedMessage() default, semanticProperties, "path", - DateTimeOffset.UtcNow, + DateTime.UtcNow, 1, "source", "Hello {Name}!", @@ -184,7 +184,7 @@ public void AkkaLogState_ShouldBeEnumerableMultipleTimes() default, semanticProperties, "path", - DateTimeOffset.UtcNow, + DateTime.UtcNow, 1, "source", "template", @@ -210,7 +210,7 @@ public void AkkaLogState_TraceContext_ShouldStoreStructsDirectly() activityContext, new Dictionary(), "path", - DateTimeOffset.UtcNow, + DateTime.UtcNow, 1, "source", "template", @@ -240,7 +240,7 @@ public void AkkaLogState_EmptySemanticProperties_ShouldStillYieldAkkaMetadata() default, emptyProperties, "akka://test/user/actor", - DateTimeOffset.UtcNow, + DateTime.UtcNow, 99, "TestSource", "template", diff --git a/src/Akka.Hosting.Tests/Logging/AkkaTraceContextProcessorSpecs.cs b/src/Akka.Hosting.Tests/Logging/AkkaTraceContextProcessorSpecs.cs index ee4a4792..afb2d43b 100644 --- a/src/Akka.Hosting.Tests/Logging/AkkaTraceContextProcessorSpecs.cs +++ b/src/Akka.Hosting.Tests/Logging/AkkaTraceContextProcessorSpecs.cs @@ -245,7 +245,7 @@ public void Processor_ShouldWork_WithAkkaLogState() activityContext, new Dictionary { { "Key", "Value" } }, "akka://test/user/actor", - DateTimeOffset.UtcNow, + DateTime.UtcNow, 1, "TestSource", "Template with {Key}", diff --git a/src/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs b/src/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs index ebdc829a..93a3bc6d 100644 --- a/src/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs +++ b/src/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs @@ -61,7 +61,7 @@ private void AssertMetadata(BugReproLogEntry entry, string? format = null) entry.State.Should().ContainKey("{OriginalFormat}"); entry.State["ActorPath"].Should().BeOfType(); - entry.State["Timestamp"].Should().BeOfType(); + entry.State["Timestamp"].Should().BeOfType(); entry.State["Thread"].Should().BeOfType(); entry.State["LogSource"].Should().BeOfType(); entry.State["{OriginalFormat}"].Should().BeOfType(); diff --git a/src/Akka.Hosting/Logging/AkkaLogState.cs b/src/Akka.Hosting/Logging/AkkaLogState.cs index c052476d..832a9e8f 100644 --- a/src/Akka.Hosting/Logging/AkkaLogState.cs +++ b/src/Akka.Hosting/Logging/AkkaLogState.cs @@ -47,7 +47,7 @@ namespace Akka.Hosting.Logging private readonly ActivityContext _activityContext; private readonly IReadOnlyDictionary? _semanticProperties; private readonly string _actorPath; - private readonly DateTimeOffset _timestamp; + private readonly DateTime _timestamp; private readonly int _threadId; private readonly string _logSource; private readonly string _template; @@ -69,7 +69,7 @@ public AkkaLogState( ActivityContext activityContext, IReadOnlyDictionary semanticProperties, string actorPath, - DateTimeOffset timestamp, + DateTime timestamp, int threadId, string logSource, string template, From 5df7cd309afd14748e1c2e32a8983e5017c8b720 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 29 Jan 2026 09:38:15 -0600 Subject: [PATCH 08/17] Revert "fix: store Akka log timestamp as DateTime" This reverts commit 8f9917caafae1ea5d8009c61b888d2f7ab6cf223. --- .../verify/CoreApiSpec.ApproveCore.verified.txt | 4 ++-- .../Logging/AkkaLogStateSpecs.cs | 14 +++++++------- .../Logging/AkkaTraceContextProcessorSpecs.cs | 2 +- .../Issue701SemanticLoggingRegressionSpecs.cs | 2 +- src/Akka.Hosting/Logging/AkkaLogState.cs | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveCore.verified.txt b/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveCore.verified.txt index 297e1e8e..641a9390 100644 --- a/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveCore.verified.txt +++ b/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveCore.verified.txt @@ -261,7 +261,7 @@ namespace Akka.Hosting.Logging public const string TraceFlagsKey = "Akka.TraceFlags"; public const string TraceIdKey = "Akka.TraceId"; public AkkaLogState(System.Diagnostics.ActivityContext activityContext, string formattedMessage) { } - public AkkaLogState(System.Diagnostics.ActivityContext activityContext, System.Collections.Generic.IReadOnlyDictionary semanticProperties, string actorPath, System.DateTime timestamp, int threadId, string logSource, string template, string formattedMessage) { } + public AkkaLogState(System.Diagnostics.ActivityContext activityContext, System.Collections.Generic.IReadOnlyDictionary semanticProperties, string actorPath, System.DateTimeOffset timestamp, int threadId, string logSource, string template, string formattedMessage) { } public System.Collections.Generic.IEnumerator> GetEnumerator() { } public override string ToString() { } } @@ -283,4 +283,4 @@ namespace Akka.Hosting.Logging public LoggerFactorySetup(Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) { } public Microsoft.Extensions.Logging.ILoggerFactory LoggerFactory { get; } } -} +} \ No newline at end of file diff --git a/src/Akka.Hosting.Tests/Logging/AkkaLogStateSpecs.cs b/src/Akka.Hosting.Tests/Logging/AkkaLogStateSpecs.cs index 21ce314d..82698992 100644 --- a/src/Akka.Hosting.Tests/Logging/AkkaLogStateSpecs.cs +++ b/src/Akka.Hosting.Tests/Logging/AkkaLogStateSpecs.cs @@ -31,7 +31,7 @@ public void AkkaLogState_WithSemanticProperties_ShouldYieldAllProperties() }; var actorPath = "akka://test/user/myactor"; - var timestamp = DateTime.UtcNow; + var timestamp = DateTimeOffset.UtcNow; var threadId = 42; var logSource = "MyActor"; var template = "User {UserId} performed {Action}"; @@ -65,7 +65,7 @@ public void AkkaLogState_WithSemanticProperties_ShouldYieldAllProperties() // Verify Akka metadata items.Should().Contain(kvp => kvp.Key == "ActorPath" && (string)kvp.Value! == actorPath); - items.Should().Contain(kvp => kvp.Key == "Timestamp" && (DateTime)kvp.Value! == timestamp); + items.Should().Contain(kvp => kvp.Key == "Timestamp" && (DateTimeOffset)kvp.Value! == timestamp); items.Should().Contain(kvp => kvp.Key == "Thread" && (int)kvp.Value! == threadId); items.Should().Contain(kvp => kvp.Key == "LogSource" && (string)kvp.Value! == logSource); @@ -89,7 +89,7 @@ public void AkkaLogState_WithoutTraceContext_ShouldOmitTraceProperties() activityContext, semanticProperties, "akka://test/user/actor", - DateTime.UtcNow, + DateTimeOffset.UtcNow, 1, "Source", "Template", @@ -165,7 +165,7 @@ public void AkkaLogState_ToString_ShouldReturnFormattedMessage() default, semanticProperties, "path", - DateTime.UtcNow, + DateTimeOffset.UtcNow, 1, "source", "Hello {Name}!", @@ -184,7 +184,7 @@ public void AkkaLogState_ShouldBeEnumerableMultipleTimes() default, semanticProperties, "path", - DateTime.UtcNow, + DateTimeOffset.UtcNow, 1, "source", "template", @@ -210,7 +210,7 @@ public void AkkaLogState_TraceContext_ShouldStoreStructsDirectly() activityContext, new Dictionary(), "path", - DateTime.UtcNow, + DateTimeOffset.UtcNow, 1, "source", "template", @@ -240,7 +240,7 @@ public void AkkaLogState_EmptySemanticProperties_ShouldStillYieldAkkaMetadata() default, emptyProperties, "akka://test/user/actor", - DateTime.UtcNow, + DateTimeOffset.UtcNow, 99, "TestSource", "template", diff --git a/src/Akka.Hosting.Tests/Logging/AkkaTraceContextProcessorSpecs.cs b/src/Akka.Hosting.Tests/Logging/AkkaTraceContextProcessorSpecs.cs index afb2d43b..ee4a4792 100644 --- a/src/Akka.Hosting.Tests/Logging/AkkaTraceContextProcessorSpecs.cs +++ b/src/Akka.Hosting.Tests/Logging/AkkaTraceContextProcessorSpecs.cs @@ -245,7 +245,7 @@ public void Processor_ShouldWork_WithAkkaLogState() activityContext, new Dictionary { { "Key", "Value" } }, "akka://test/user/actor", - DateTime.UtcNow, + DateTimeOffset.UtcNow, 1, "TestSource", "Template with {Key}", diff --git a/src/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs b/src/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs index 93a3bc6d..ebdc829a 100644 --- a/src/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs +++ b/src/Akka.Hosting.Tests/Logging/Issue701SemanticLoggingRegressionSpecs.cs @@ -61,7 +61,7 @@ private void AssertMetadata(BugReproLogEntry entry, string? format = null) entry.State.Should().ContainKey("{OriginalFormat}"); entry.State["ActorPath"].Should().BeOfType(); - entry.State["Timestamp"].Should().BeOfType(); + entry.State["Timestamp"].Should().BeOfType(); entry.State["Thread"].Should().BeOfType(); entry.State["LogSource"].Should().BeOfType(); entry.State["{OriginalFormat}"].Should().BeOfType(); diff --git a/src/Akka.Hosting/Logging/AkkaLogState.cs b/src/Akka.Hosting/Logging/AkkaLogState.cs index 832a9e8f..c052476d 100644 --- a/src/Akka.Hosting/Logging/AkkaLogState.cs +++ b/src/Akka.Hosting/Logging/AkkaLogState.cs @@ -47,7 +47,7 @@ namespace Akka.Hosting.Logging private readonly ActivityContext _activityContext; private readonly IReadOnlyDictionary? _semanticProperties; private readonly string _actorPath; - private readonly DateTime _timestamp; + private readonly DateTimeOffset _timestamp; private readonly int _threadId; private readonly string _logSource; private readonly string _template; @@ -69,7 +69,7 @@ public AkkaLogState( ActivityContext activityContext, IReadOnlyDictionary semanticProperties, string actorPath, - DateTime timestamp, + DateTimeOffset timestamp, int threadId, string logSource, string template, From 96c13f53f1a332cdb429f943a74b2b840d4eac07 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 29 Jan 2026 10:27:44 -0600 Subject: [PATCH 09/17] chore: simplify Aspire demo and rely on OTLP --- .../Akka.Hosting.OpenTelemetry.AppHost.csproj | 1 - .../Akka.Hosting.OpenTelemetry.AppHost/Program.cs | 9 ++------- .../Akka.Hosting.OpenTelemetry.Demo.csproj | 1 - .../Akka.Hosting.OpenTelemetry.Demo/Extensions.cs | 15 --------------- .../Akka.Hosting.OpenTelemetry.Demo/Program.cs | 11 ++++++++++- 5 files changed, 12 insertions(+), 25 deletions(-) diff --git a/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Akka.Hosting.OpenTelemetry.AppHost.csproj b/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Akka.Hosting.OpenTelemetry.AppHost.csproj index ef2daaa8..0d9f9cee 100644 --- a/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Akka.Hosting.OpenTelemetry.AppHost.csproj +++ b/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Akka.Hosting.OpenTelemetry.AppHost.csproj @@ -13,7 +13,6 @@ - diff --git a/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Program.cs b/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Program.cs index 40979eb7..0dc72532 100644 --- a/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Program.cs +++ b/src/Examples/Akka.Hosting.OpenTelemetry.AppHost/Program.cs @@ -6,12 +6,7 @@ var builder = DistributedApplication.CreateBuilder(args); -// Add Seq for log collection and visualization -var seq = builder.AddSeq("seq") - .WithDataVolume(); - -// Add the Akka.NET demo service with Seq reference for OTLP export -builder.AddProject("demo") - .WithReference(seq); +// Add the Akka.NET demo service +builder.AddProject("demo"); builder.Build().Run(); diff --git a/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Akka.Hosting.OpenTelemetry.Demo.csproj b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Akka.Hosting.OpenTelemetry.Demo.csproj index 34f58cd9..69d13994 100644 --- a/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Akka.Hosting.OpenTelemetry.Demo.csproj +++ b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Akka.Hosting.OpenTelemetry.Demo.csproj @@ -9,7 +9,6 @@ - diff --git a/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Extensions.cs b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Extensions.cs index 0820d48a..0a31bd82 100644 --- a/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Extensions.cs +++ b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Extensions.cs @@ -4,7 +4,6 @@ // // ----------------------------------------------------------------------- -using OpenTelemetry.Exporter; using OpenTelemetry.Trace; namespace Akka.Hosting.OpenTelemetry.Demo; @@ -29,20 +28,6 @@ public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBu tracing.AddSource("Akka.Hosting.OpenTelemetry.Demo"); }); - // Add OTLP exporters if configured - var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; - if (!string.IsNullOrWhiteSpace(otlpEndpoint)) - { - builder.Services.AddOpenTelemetry() - .WithTracing(tracing => - { - tracing.AddOtlpExporter(options => - { - options.Endpoint = new Uri(otlpEndpoint); - }); - }); - } - // Add health checks builder.Services.AddHealthChecks(); diff --git a/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Program.cs b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Program.cs index 08ed9969..60152ebb 100644 --- a/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Program.cs +++ b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Program.cs @@ -15,7 +15,7 @@ var builder = Host.CreateApplicationBuilder(args); -// Add Aspire service defaults (includes Seq integration via OTLP) +// Add Aspire service defaults (service discovery + tracing) builder.AddServiceDefaults(); // Configure OpenTelemetry logging with Akka trace correlation @@ -31,6 +31,15 @@ // Include formatted message for easier debugging options.IncludeFormattedMessage = true; options.IncludeScopes = true; + + var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; + if (!string.IsNullOrWhiteSpace(otlpEndpoint)) + { + options.AddOtlpExporter(exporterOptions => + { + exporterOptions.Endpoint = new Uri(otlpEndpoint); + }); + } }); // Configure Akka.NET with LoggerFactoryLogger From 95a430d19ba9ef8851c01b4c544d494965230840 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 29 Jan 2026 10:51:31 -0600 Subject: [PATCH 10/17] chore: enable OTLP exporters in demo --- .../Akka.Hosting.OpenTelemetry.Demo/Extensions.cs | 1 + src/Examples/Akka.Hosting.OpenTelemetry.Demo/Program.cs | 9 +-------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Extensions.cs b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Extensions.cs index 0a31bd82..a05c1ddb 100644 --- a/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Extensions.cs +++ b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Extensions.cs @@ -26,6 +26,7 @@ public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBu .WithTracing(tracing => { tracing.AddSource("Akka.Hosting.OpenTelemetry.Demo"); + tracing.AddOtlpExporter(); }); // Add health checks diff --git a/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Program.cs b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Program.cs index 60152ebb..8eae3073 100644 --- a/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Program.cs +++ b/src/Examples/Akka.Hosting.OpenTelemetry.Demo/Program.cs @@ -32,14 +32,7 @@ options.IncludeFormattedMessage = true; options.IncludeScopes = true; - var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; - if (!string.IsNullOrWhiteSpace(otlpEndpoint)) - { - options.AddOtlpExporter(exporterOptions => - { - exporterOptions.Endpoint = new Uri(otlpEndpoint); - }); - } + options.AddOtlpExporter(); }); // Configure Akka.NET with LoggerFactoryLogger From 6e3c53ef86725ca1c84654811f0f3a39d9102651 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 29 Jan 2026 10:58:47 -0600 Subject: [PATCH 11/17] breaking: make AkkaLogState internal and enable OTLP export --- .../verify/CoreApiSpec.ApproveCore.verified.txt | 12 +----------- src/Akka.Hosting/Logging/AkkaLogState.cs | 2 +- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveCore.verified.txt b/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveCore.verified.txt index 641a9390..e54f8bb0 100644 --- a/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveCore.verified.txt +++ b/src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveCore.verified.txt @@ -255,16 +255,6 @@ namespace Akka.Hosting.HealthChecks } namespace Akka.Hosting.Logging { - public readonly struct AkkaLogState : System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable - { - public const string SpanIdKey = "Akka.SpanId"; - public const string TraceFlagsKey = "Akka.TraceFlags"; - public const string TraceIdKey = "Akka.TraceId"; - public AkkaLogState(System.Diagnostics.ActivityContext activityContext, string formattedMessage) { } - public AkkaLogState(System.Diagnostics.ActivityContext activityContext, System.Collections.Generic.IReadOnlyDictionary semanticProperties, string actorPath, System.DateTimeOffset timestamp, int threadId, string logSource, string template, string formattedMessage) { } - public System.Collections.Generic.IEnumerator> GetEnumerator() { } - public override string ToString() { } - } public sealed class AkkaTraceContextProcessor : OpenTelemetry.BaseProcessor { public AkkaTraceContextProcessor() { } @@ -283,4 +273,4 @@ namespace Akka.Hosting.Logging public LoggerFactorySetup(Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) { } public Microsoft.Extensions.Logging.ILoggerFactory LoggerFactory { get; } } -} \ No newline at end of file +} diff --git a/src/Akka.Hosting/Logging/AkkaLogState.cs b/src/Akka.Hosting/Logging/AkkaLogState.cs index c052476d..a934595c 100644 --- a/src/Akka.Hosting/Logging/AkkaLogState.cs +++ b/src/Akka.Hosting/Logging/AkkaLogState.cs @@ -27,7 +27,7 @@ namespace Akka.Hosting.Logging /// it uses . /// /// - public readonly struct AkkaLogState : IEnumerable> + internal readonly struct AkkaLogState : IEnumerable> { /// /// Key for the trace ID in the log state dictionary. From c8c251e78d4970988ea0cd0580902bcc07c8537f Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 29 Jan 2026 11:05:42 -0600 Subject: [PATCH 12/17] refactor: use unsafe accessor for log trace context --- .../Logging/AkkaTraceContextProcessor.cs | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Akka.Hosting/Logging/AkkaTraceContextProcessor.cs b/src/Akka.Hosting/Logging/AkkaTraceContextProcessor.cs index 275f94c4..cc89c4d7 100644 --- a/src/Akka.Hosting/Logging/AkkaTraceContextProcessor.cs +++ b/src/Akka.Hosting/Logging/AkkaTraceContextProcessor.cs @@ -6,6 +6,7 @@ using System; using System.Diagnostics; +using System.Runtime.CompilerServices; using OpenTelemetry; using OpenTelemetry.Logs; @@ -111,18 +112,11 @@ public override void OnEnd(LogRecord data) private static void SetTraceContext(LogRecord record, ActivityTraceId traceId, ActivitySpanId spanId, ActivityTraceFlags traceFlags) { - // LogRecord has internal setters, so we need to use reflection try { - var recordType = typeof(LogRecord); - - var traceIdProp = recordType.GetProperty("TraceId"); - var spanIdProp = recordType.GetProperty("SpanId"); - var traceFlagsProp = recordType.GetProperty("TraceFlags"); - - traceIdProp?.SetValue(record, traceId); - spanIdProp?.SetValue(record, spanId); - traceFlagsProp?.SetValue(record, traceFlags); + SetTraceId(record, traceId); + SetSpanId(record, spanId); + SetTraceFlags(record, traceFlags); } catch { @@ -131,6 +125,15 @@ private static void SetTraceContext(LogRecord record, ActivityTraceId traceId, A } } + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_TraceId")] + private static extern void SetTraceId(LogRecord record, ActivityTraceId value); + + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_SpanId")] + private static extern void SetSpanId(LogRecord record, ActivitySpanId value); + + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_TraceFlags")] + private static extern void SetTraceFlags(LogRecord record, ActivityTraceFlags value); + private static ActivityTraceId? TryParseTraceId(string traceIdStr) { try From 277e931734a1ed3a7bba279390ca966e281799f0 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 29 Jan 2026 11:15:12 -0600 Subject: [PATCH 13/17] fix: revert to reflection for LogRecord trace setters --- .../Logging/AkkaTraceContextProcessor.cs | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/Akka.Hosting/Logging/AkkaTraceContextProcessor.cs b/src/Akka.Hosting/Logging/AkkaTraceContextProcessor.cs index cc89c4d7..275f94c4 100644 --- a/src/Akka.Hosting/Logging/AkkaTraceContextProcessor.cs +++ b/src/Akka.Hosting/Logging/AkkaTraceContextProcessor.cs @@ -6,7 +6,6 @@ using System; using System.Diagnostics; -using System.Runtime.CompilerServices; using OpenTelemetry; using OpenTelemetry.Logs; @@ -112,11 +111,18 @@ public override void OnEnd(LogRecord data) private static void SetTraceContext(LogRecord record, ActivityTraceId traceId, ActivitySpanId spanId, ActivityTraceFlags traceFlags) { + // LogRecord has internal setters, so we need to use reflection try { - SetTraceId(record, traceId); - SetSpanId(record, spanId); - SetTraceFlags(record, traceFlags); + var recordType = typeof(LogRecord); + + var traceIdProp = recordType.GetProperty("TraceId"); + var spanIdProp = recordType.GetProperty("SpanId"); + var traceFlagsProp = recordType.GetProperty("TraceFlags"); + + traceIdProp?.SetValue(record, traceId); + spanIdProp?.SetValue(record, spanId); + traceFlagsProp?.SetValue(record, traceFlags); } catch { @@ -125,15 +131,6 @@ private static void SetTraceContext(LogRecord record, ActivityTraceId traceId, A } } - [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_TraceId")] - private static extern void SetTraceId(LogRecord record, ActivityTraceId value); - - [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_SpanId")] - private static extern void SetSpanId(LogRecord record, ActivitySpanId value); - - [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_TraceFlags")] - private static extern void SetTraceFlags(LogRecord record, ActivityTraceFlags value); - private static ActivityTraceId? TryParseTraceId(string traceIdStr) { try From 8528b5330f0fd9bbb7bf3d6c3ba8dda2c3ac3bc6 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 29 Jan 2026 11:25:48 -0600 Subject: [PATCH 14/17] docs: add OpenTelemetry trace correlation section --- README.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/README.md b/README.md index a4e16c6d..90edb4db 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ See the ["Introduction to Akka.Hosting - HOCON-less, "Pit of Success" Akka.NET R * [Microsoft.Extensions.Logging.ILoggerFactory Logging Support](#microsoftextensionsloggingiloggerfactory-logging-support) * [Serilog Support](#serilog-support) * [Microsoft.Extensions.Logging Log Event Filtering](#microsoftextensionslogging-log-event-filtering) +- [OpenTelemetry Trace Correlation](#opentelemetry-trace-correlation) - [Microsoft.Extensions.Diagnostics.HealthChecks Integration](#healthchecks) * [Dependency Injected Health Checks](#healthcheck-di) * [Built-in HealthChecks](#healthcheck-builtin) @@ -629,6 +630,44 @@ To set up the `Microsoft.Extensions.Logging` log filtering, you will need to edi [Back to top](#akkahosting) + +# OpenTelemetry Trace Correlation + +Akka.NET processes log events asynchronously, which means `Activity.Current` does not flow across actor mailbox boundaries. To preserve trace correlation, Akka.Hosting captures the `ActivityContext` at log creation time and includes it in the log state. The `AkkaTraceContextProcessor` then applies that context to OpenTelemetry `LogRecord`s so exporters can correlate logs with traces. + +Minimal setup: + +```csharp +using Akka.Hosting; +using Akka.Hosting.Logging; +using OpenTelemetry.Logs; +using OpenTelemetry.Resources; + +builder.Logging.AddOpenTelemetry(options => +{ + options.SetResourceBuilder(ResourceBuilder.CreateDefault() + .AddService("my-service")); + + // Register before exporters + options.AddAkkaTraceCorrelation(); + + options.AddOtlpExporter(); +}); + +builder.Services.AddAkka("MySystem", configBuilder => +{ + configBuilder.ConfigureLoggers(setup => + { + setup.ClearLoggers(); + setup.AddLoggerFactory(); + }); +}); +``` + +See the demo in `src/Examples/Akka.Hosting.OpenTelemetry.AppHost` and `src/Examples/Akka.Hosting.OpenTelemetry.Demo` for a working Aspire setup. + +[Back to top](#akkahosting) + ## Filtering Logs In Akka.NET From ce4c7ccf51ed7ed0388e34e31246728f6400563e Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 29 Jan 2026 11:47:41 -0600 Subject: [PATCH 15/17] chore: revert dependency version bumps --- Directory.Build.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 24b68fd0..7fe838e1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -35,8 +35,8 @@ 6.0.3 3.1.5 1.5.59 - [8.0.0,) - [8.0.5,) + [6.0.0,) + [6.0.10,) From d45eff2b98cfa6e2cb8039cad7c43608312608ce Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 29 Jan 2026 12:04:10 -0600 Subject: [PATCH 16/17] chore: align Microsoft.Extensions versions with OpenTelemetry --- Directory.Build.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 7fe838e1..24b68fd0 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -35,8 +35,8 @@ 6.0.3 3.1.5 1.5.59 - [6.0.0,) - [6.0.10,) + [8.0.0,) + [8.0.5,) From e11b4ea44d363fc0afe44a1b7c9cc4af63c99272 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 29 Jan 2026 12:10:07 -0600 Subject: [PATCH 17/17] docs: clarify OTLP exporter guidance --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 90edb4db..e1e8902b 100644 --- a/README.md +++ b/README.md @@ -651,6 +651,8 @@ builder.Logging.AddOpenTelemetry(options => // Register before exporters options.AddAkkaTraceCorrelation(); + // Add OTLP exporter if you have not configured it elsewhere. + // Your mileage may vary; use the OpenTelemetry configuration that fits your app. options.AddOtlpExporter(); });