From 76035cd9dda215c836c7b9df8de66feea67877a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:07:58 +0200 Subject: [PATCH 01/16] feat(logs): add `log4net` integration --- src/Sentry.Log4Net/LevelMapping.cs | 16 ++ src/Sentry.Log4Net/SentryAppender.cs | 36 +++ src/Sentry/Sentry.csproj | 3 + src/Sentry/SentryLog.Factory.cs | 18 ++ src/Sentry/SentryLog.cs | 2 +- .../SentryAppenderTests.cs | 211 +++++++++++++++++- 6 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 src/Sentry/SentryLog.Factory.cs diff --git a/src/Sentry.Log4Net/LevelMapping.cs b/src/Sentry.Log4Net/LevelMapping.cs index e22dc24d7e..1e31f0ab94 100644 --- a/src/Sentry.Log4Net/LevelMapping.cs +++ b/src/Sentry.Log4Net/LevelMapping.cs @@ -29,4 +29,20 @@ internal static class LevelMapping _ => null }; } + + public static SentryLogLevel? ToSentryLogLevel(this LoggingEvent loggingEvent) + { + return loggingEvent.Level switch + { + var level when level == Level.Off => null, + var level when level >= Level.Fatal => SentryLogLevel.Fatal, + var level when level >= Level.Error => SentryLogLevel.Error, + var level when level >= Level.Warn => SentryLogLevel.Warning, + var level when level >= Level.Info => SentryLogLevel.Info, + var level when level >= Level.Debug => SentryLogLevel.Debug, + var level when level >= Level.Trace => SentryLogLevel.Trace, + var level when level >= Level.All => SentryLogLevel.Trace, + _ => null, + }; + } } diff --git a/src/Sentry.Log4Net/SentryAppender.cs b/src/Sentry.Log4Net/SentryAppender.cs index 8b8f3c51de..7d69eb2862 100644 --- a/src/Sentry.Log4Net/SentryAppender.cs +++ b/src/Sentry.Log4Net/SentryAppender.cs @@ -15,6 +15,12 @@ public class SentryAppender : AppenderSkeleton internal static readonly SdkVersion NameAndVersion = typeof(SentryAppender).Assembly.GetNameAndVersion(); + private static readonly SdkVersion Sdk = new() + { + Name = SdkName, + Version = NameAndVersion.Version, + }; + private static readonly string ProtocolPackageName = "nuget:" + NameAndVersion.Name; private readonly IHub _hub; @@ -84,6 +90,36 @@ protected override void Append(LoggingEvent loggingEvent) } } + var options = _hub.GetSentryOptions(); + if (options is { EnableLogs: true }) + { + var level = loggingEvent.ToSentryLogLevel(); + if (level.HasValue) + { + DateTimeOffset timestamp = new(loggingEvent.TimeStampUtc); + string? template = null; // cannot get format-string from `log4net.Util.SystemStringFormat` via `LoggingEvent.MessageObject` + var parameters = ImmutableArray>.Empty; // cannot get arguments from `log4net.Util.SystemStringFormat` via `LoggingEvent.MessageObject` + + var log = SentryLog.Create(_hub, timestamp, level.Value, loggingEvent.RenderedMessage, template, parameters); + + log.SetDefaultAttributes(options, Sdk); + log.SetOrigin("auto.log.log4net"); + + foreach (var property in loggingEvent.GetProperties()) + { + if (property is DictionaryEntry { Key: string key, Value: { } value}) + { + if (key.Length != 0 && !key.StartsWith("log4net:", StringComparison.OrdinalIgnoreCase)) + { + log.SetAttribute($"property.{key}", value); + } + } + } + + _hub.Logger.CaptureLog(log); + } + } + var exception = loggingEvent.ExceptionObject ?? loggingEvent.MessageObject as Exception; if (MinimumEventLevel is not null && loggingEvent.Level < MinimumEventLevel) diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index bbe7753ed4..81370713a7 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -193,6 +193,9 @@ MeasurementUnit.cs + + SentryLog.cs + SentryMetric.cs diff --git a/src/Sentry/SentryLog.Factory.cs b/src/Sentry/SentryLog.Factory.cs new file mode 100644 index 0000000000..bcb7149bba --- /dev/null +++ b/src/Sentry/SentryLog.Factory.cs @@ -0,0 +1,18 @@ +namespace Sentry; + +public sealed partial class SentryLog +{ + internal static SentryLog Create(IHub hub, DateTimeOffset timestamp, SentryLogLevel level, string message, string? template, ImmutableArray> parameters) + { + hub.GetTraceIdAndSpanId(out var traceId, out var spanId); + + SentryLog log = new(timestamp, traceId, level, message) + { + Template = template, + Parameters = parameters, + SpanId = spanId, + }; + + return log; + } +} diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index 3cb503cdba..69fa399b26 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -12,7 +12,7 @@ namespace Sentry; /// Sentry .NET SDK Docs: . /// [DebuggerDisplay(@"SentryLog \{ Level = {Level}, Message = '{Message}' \}")] -public sealed class SentryLog +public sealed partial class SentryLog { private readonly Dictionary _attributes; diff --git a/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs b/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs index 03cd2501a3..6fdf9c38d0 100644 --- a/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs +++ b/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs @@ -1,6 +1,8 @@ +using log4net.Util; + namespace Sentry.Log4Net.Tests; -public class SentryAppenderTests +public class SentryAppenderTests : IDisposable { private class Fixture { @@ -12,6 +14,7 @@ private class Fixture public Func HubAccessor { get; set; } public Scope Scope { get; } = new(new SentryOptions()); public string Dsn { get; set; } = "dsn"; + public SentryOptions Options { get; } = new(); public Fixture() { @@ -27,6 +30,8 @@ public Fixture() public SentryAppender GetSut() { + SentryClientExtensions.SentryOptionsForTestingOnly = Options; + var sut = new SentryAppender(InitAction, Hub) { Dsn = Dsn @@ -38,6 +43,11 @@ public SentryAppender GetSut() private readonly Fixture _fixture = new(); + public void Dispose() + { + SentryClientExtensions.SentryOptionsForTestingOnly = null; + } + [Fact] public void Append_WithException_CreatesEventWithException() { @@ -379,6 +389,192 @@ public void DoAppend_AboveMinimumEventLevel_AddsEvent() .CaptureEvent(Arg.Is(e => e.Message.Message == expectedMessage)); } +#nullable enable + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DoAppend_StructuredLogging_IsEnabled(bool isEnabled) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = isEnabled; + + var sut = _fixture.GetSut(); + + sut.DoAppend(CreateLoggingEvent(Level.Info, "Message")); + + capturer.Logs.Should().HaveCount(isEnabled ? 1 : 0); + } + + public static TheoryData LogLevelData => new() + { + { Level.All, SentryLogLevel.Trace }, + { Level.Finest, SentryLogLevel.Trace }, + { Level.Verbose, SentryLogLevel.Trace }, + { Level.Finer, SentryLogLevel.Trace }, + { Level.Trace, SentryLogLevel.Trace }, + { Level.Fine, SentryLogLevel.Debug }, + { Level.Debug, SentryLogLevel.Debug }, + { Level.Info, SentryLogLevel.Info }, + { Level.Notice, SentryLogLevel.Info }, + { Level.Warn, SentryLogLevel.Warning }, + { Level.Error, SentryLogLevel.Error }, + { Level.Severe, SentryLogLevel.Error }, + { Level.Critical, SentryLogLevel.Error }, + { Level.Alert, SentryLogLevel.Error }, + { Level.Fatal, SentryLogLevel.Fatal }, + { Level.Emergency, SentryLogLevel.Fatal }, + { Level.Log4Net_Debug, SentryLogLevel.Fatal }, + { new Level(0, "DEFAULT"), SentryLogLevel.Trace }, + { new Level(-1, "CUSTOM"), SentryLogLevel.Trace }, + }; + + [Theory] + [MemberData(nameof(LogLevelData))] + public void DoAppend_StructuredLogging_LogLevel(Level level, SentryLogLevel expected) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + + sut.DoAppend(CreateLoggingEvent(level, "Message")); + + capturer.Logs.Should().ContainSingle().Which.Level.Should().Be(expected); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DoAppend_StructuredLogging_LogEvent(bool withActiveSpan) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + _fixture.Options.Environment = "test-environment"; + _fixture.Options.Release = "test-release"; + + if (withActiveSpan) + { + var span = Substitute.For(); + span.TraceId.Returns(SentryId.Create()); + span.SpanId.Returns(SpanId.Create()); + _fixture.Hub.GetSpan().Returns(span); + } + else + { + _fixture.Hub.GetSpan().Returns((ISpan?)null); + } + + var sut = _fixture.GetSut(); + ThreadContext.Properties["Text-Property"] = "4"; + ThreadContext.Properties["Number-Property"] = 4; + ThreadContext.Properties["Collection-Property"] = new[] { 3, 4, 5 }; + ThreadContext.Properties["Object-Property"] = (Number: 4, Text: "4"); + + sut.DoAppend(CreateLoggingEvent(Level.Info, "{0}, {1}, {2}", [0, 1, 2])); + + var log = capturer.Logs.Should().ContainSingle().Which; + log.Timestamp.Should().BeOnOrBefore(DateTimeOffset.Now); + log.TraceId.Should().Be(withActiveSpan ? _fixture.Hub.GetSpan()!.TraceId : _fixture.Scope.PropagationContext.TraceId); + log.Level.Should().Be(SentryLogLevel.Info); + log.Message.Should().Be("0, 1, 2"); + log.Template.Should().BeNull(); + log.Parameters.Should().BeEmpty(); + log.SpanId.Should().Be(withActiveSpan ? _fixture.Hub.GetSpan()!.SpanId : null); + + log.TryGetAttribute("sentry.environment", out object? environment).Should().BeTrue(); + environment.Should().Be("test-environment"); + log.TryGetAttribute("sentry.release", out object? release).Should().BeTrue(); + release.Should().Be("test-release"); + log.TryGetAttribute("sentry.origin", out object? origin).Should().BeTrue(); + origin.Should().Be("auto.log.log4net"); + log.TryGetAttribute("sentry.sdk.name", out object? sdkName).Should().BeTrue(); + sdkName.Should().Be(SentryAppender.SdkName); + log.TryGetAttribute("sentry.sdk.version", out object? sdkVersion).Should().BeTrue(); + sdkVersion.Should().Be(SentryAppender.NameAndVersion.Version); + + log.TryGetAttribute("property.Text-Property", out object? text).Should().BeTrue(); + text.Should().Be("4"); + log.TryGetAttribute("property.Number-Property", out object? number).Should().BeTrue(); + number.Should().Be(4); + log.TryGetAttribute("property.Collection-Property", out object? collection).Should().BeTrue(); + collection.Should().BeEquivalentTo(new[] { 3, 4, 5 }); + log.TryGetAttribute("property.Object-Property", out object? obj).Should().BeTrue(); + obj.Should().Be((Number: 4, Text: "4")); + } + + [Fact] + public void DoAppend_StructuredLogging__LogLevel() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + + LoggingEventData data = new() + { + LoggerName = "TestLogger", + Level = Level.Info, + Message = "Test Message", + ThreadName = "1", + LocationInfo = new LocationInfo(null), + UserName = "TestUser", + Identity = "TestIdentity", + ExceptionString = "Exception", + Domain = "TestDomain", + Properties = new PropertiesDictionary(), + TimeStampUtc = DateTime.UtcNow, + }; + LoggingEvent loggingEvent = new(data); + sut.DoAppend(loggingEvent); + + var log = capturer.Logs.Should().ContainSingle().Which; + log.Level.Should().Be(SentryLogLevel.Warning); + log.Message.Should().Be("Test Message"); + + //TODO: assert Count/Length of Attributes + //requires: https://github.com/getsentry/sentry-dotnet/pull/4936 + //should not contain "log4net:.." properties + } + + [Fact] + public void DoAppend_StructuredLoggingWithException_NoBreadcrumb() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + sut.MinimumEventLevel = Level.Error; + + sut.DoAppend(CreateLoggingEvent(Level.Error, "Message", new Exception("expected message"))); + + capturer.Logs.Should().ContainSingle().Which.Message.Should().Be("Message"); + _fixture.Scope.Breadcrumbs.Should().BeEmpty(); + _ = _fixture.Hub.Received(1).CaptureEvent(Arg.Any()); + } + + [Fact] + public void DoAppend_StructuredLoggingWithoutException_LeavesBreadcrumb() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + sut.MinimumEventLevel = Level.Fatal; + + sut.DoAppend(CreateLoggingEvent(Level.Error, "Message")); + + capturer.Logs.Should().ContainSingle().Which.Message.Should().Be("Message"); + _fixture.Scope.Breadcrumbs.Should().ContainSingle().Which.Message.Should().Be("Message"); + _ = _fixture.Hub.Received(0).CaptureEvent(Arg.Any()); + } +#nullable restore + [Fact] public void Close_DisposesSdk() { @@ -395,4 +591,17 @@ public void Close_DisposesSdk() _fixture.SdkDisposeHandle.Received(1).Dispose(); } + +#nullable enable + private static LoggingEvent CreateLoggingEvent(Level level, string message, Exception? exception = null) + { + return new LoggingEvent(null, null, "TestLogger", level, message, exception); + } + + private static LoggingEvent CreateLoggingEvent(Level level, string format, object[] args) + { + var message = new SystemStringFormat(CultureInfo.InvariantCulture, format, args); + return new LoggingEvent(null, null, "TestLogger", level, message, null); + } +#nullable restore } From 10916bff70fe9508c0aa62386e96d8f79bfdd6dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:13:19 +0200 Subject: [PATCH 02/16] test: fix --- test/Sentry.Log4Net.Tests/SentryAppenderTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs b/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs index 6fdf9c38d0..531bf178f6 100644 --- a/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs +++ b/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs @@ -506,7 +506,7 @@ public void DoAppend_StructuredLogging_LogEvent(bool withActiveSpan) } [Fact] - public void DoAppend_StructuredLogging__LogLevel() + public void DoAppend_StructuredLogging_Properties() { InMemorySentryStructuredLogger capturer = new(); _fixture.Hub.Logger.Returns(capturer); @@ -532,7 +532,7 @@ public void DoAppend_StructuredLogging__LogLevel() sut.DoAppend(loggingEvent); var log = capturer.Logs.Should().ContainSingle().Which; - log.Level.Should().Be(SentryLogLevel.Warning); + log.Level.Should().Be(SentryLogLevel.Info); log.Message.Should().Be("Test Message"); //TODO: assert Count/Length of Attributes From d5e3ab4cfee7d6e7f32ba86e69494c53497f9cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:17:09 +0200 Subject: [PATCH 03/16] fix log4net sample app --- samples/Sentry.Samples.Log4Net/Program.cs | 4 +++- samples/Sentry.Samples.Log4Net/Sentry.Samples.Log4Net.csproj | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/samples/Sentry.Samples.Log4Net/Program.cs b/samples/Sentry.Samples.Log4Net/Program.cs index c0f33f246f..4f5cb1297b 100644 --- a/samples/Sentry.Samples.Log4Net/Program.cs +++ b/samples/Sentry.Samples.Log4Net/Program.cs @@ -10,9 +10,11 @@ internal class Program private static void Main() { +#if NETFRAMEWORK // Set the user running the process the current principal - // Appender was configure to send the user with the event + // Appender was configured to send the user with the event AppDomain.CurrentDomain.SetPrincipalPolicy(PrincipalPolicy.WindowsPrincipal); +#endif // The following anonymous object gets serialized and sent with log messages ThreadContext.Properties["inventory"] = new diff --git a/samples/Sentry.Samples.Log4Net/Sentry.Samples.Log4Net.csproj b/samples/Sentry.Samples.Log4Net/Sentry.Samples.Log4Net.csproj index 7f0c0b8178..9fe2f12c34 100644 --- a/samples/Sentry.Samples.Log4Net/Sentry.Samples.Log4Net.csproj +++ b/samples/Sentry.Samples.Log4Net/Sentry.Samples.Log4Net.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net481 3.5.234 From d4e69a9cb71432514e6f62853f2728a60c68b70b Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Wed, 29 Apr 2026 19:32:49 +0000 Subject: [PATCH 04/16] Format code --- src/Sentry.Log4Net/SentryAppender.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Sentry.Log4Net/SentryAppender.cs b/src/Sentry.Log4Net/SentryAppender.cs index 7d69eb2862..ca1353fe7e 100644 --- a/src/Sentry.Log4Net/SentryAppender.cs +++ b/src/Sentry.Log4Net/SentryAppender.cs @@ -107,7 +107,7 @@ protected override void Append(LoggingEvent loggingEvent) foreach (var property in loggingEvent.GetProperties()) { - if (property is DictionaryEntry { Key: string key, Value: { } value}) + if (property is DictionaryEntry { Key: string key, Value: { } value }) { if (key.Length != 0 && !key.StartsWith("log4net:", StringComparison.OrdinalIgnoreCase)) { From 8b180f1c31bbfc02c2e63abcd15ce2d3182c9fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Fri, 1 May 2026 03:26:15 +0200 Subject: [PATCH 05/16] ref: move to partial class --- src/Sentry.Log4Net/Sentry.Log4Net.csproj | 6 + .../SentryAppender.Structured.cs | 33 +++ src/Sentry.Log4Net/SentryAppender.cs | 28 +-- .../Sentry.Log4Net.Tests.csproj | 6 + .../SentryAppenderTests.Structured.cs | 203 ++++++++++++++++++ .../SentryAppenderTests.cs | 203 +----------------- 6 files changed, 251 insertions(+), 228 deletions(-) create mode 100644 src/Sentry.Log4Net/SentryAppender.Structured.cs create mode 100644 test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs diff --git a/src/Sentry.Log4Net/Sentry.Log4Net.csproj b/src/Sentry.Log4Net/Sentry.Log4Net.csproj index aebe69096e..05c913f23a 100644 --- a/src/Sentry.Log4Net/Sentry.Log4Net.csproj +++ b/src/Sentry.Log4Net/Sentry.Log4Net.csproj @@ -26,4 +26,10 @@ + + + SentryAppender.cs + + + diff --git a/src/Sentry.Log4Net/SentryAppender.Structured.cs b/src/Sentry.Log4Net/SentryAppender.Structured.cs new file mode 100644 index 0000000000..a17c0baa3d --- /dev/null +++ b/src/Sentry.Log4Net/SentryAppender.Structured.cs @@ -0,0 +1,33 @@ +namespace Sentry.Log4Net; + +public partial class SentryAppender +{ + private static void CaptureStructuredLog(IHub hub, SentryOptions options, LoggingEvent loggingEvent) + { + var level = loggingEvent.ToSentryLogLevel(); + if (level.HasValue) + { + DateTimeOffset timestamp = new(loggingEvent.TimeStampUtc); + const string? template = null; // cannot get format-string from `log4net.Util.SystemStringFormat` via `LoggingEvent.MessageObject` + var parameters = ImmutableArray>.Empty; // cannot get arguments from `log4net.Util.SystemStringFormat` via `LoggingEvent.MessageObject` + + var log = SentryLog.Create(hub, timestamp, level.Value, loggingEvent.RenderedMessage, template, parameters); + + log.SetDefaultAttributes(options, Sdk); + log.SetOrigin("auto.log.log4net"); + + foreach (var property in loggingEvent.GetProperties()) + { + if (property is DictionaryEntry { Key: string key, Value: { } value }) + { + if (key.Length != 0 && !key.StartsWith("log4net:", StringComparison.OrdinalIgnoreCase)) + { + log.SetAttribute($"property.{key}", value); + } + } + } + + hub.Logger.CaptureLog(log); + } + } +} diff --git a/src/Sentry.Log4Net/SentryAppender.cs b/src/Sentry.Log4Net/SentryAppender.cs index ca1353fe7e..a6381f4c0f 100644 --- a/src/Sentry.Log4Net/SentryAppender.cs +++ b/src/Sentry.Log4Net/SentryAppender.cs @@ -5,7 +5,7 @@ namespace Sentry.Log4Net; /// /// Sentry appender for log4net. /// -public class SentryAppender : AppenderSkeleton +public partial class SentryAppender : AppenderSkeleton { private readonly Func _initAction; private volatile IDisposable? _sdkHandle; @@ -93,31 +93,7 @@ protected override void Append(LoggingEvent loggingEvent) var options = _hub.GetSentryOptions(); if (options is { EnableLogs: true }) { - var level = loggingEvent.ToSentryLogLevel(); - if (level.HasValue) - { - DateTimeOffset timestamp = new(loggingEvent.TimeStampUtc); - string? template = null; // cannot get format-string from `log4net.Util.SystemStringFormat` via `LoggingEvent.MessageObject` - var parameters = ImmutableArray>.Empty; // cannot get arguments from `log4net.Util.SystemStringFormat` via `LoggingEvent.MessageObject` - - var log = SentryLog.Create(_hub, timestamp, level.Value, loggingEvent.RenderedMessage, template, parameters); - - log.SetDefaultAttributes(options, Sdk); - log.SetOrigin("auto.log.log4net"); - - foreach (var property in loggingEvent.GetProperties()) - { - if (property is DictionaryEntry { Key: string key, Value: { } value }) - { - if (key.Length != 0 && !key.StartsWith("log4net:", StringComparison.OrdinalIgnoreCase)) - { - log.SetAttribute($"property.{key}", value); - } - } - } - - _hub.Logger.CaptureLog(log); - } + CaptureStructuredLog(_hub, options, loggingEvent); } var exception = loggingEvent.ExceptionObject ?? loggingEvent.MessageObject as Exception; diff --git a/test/Sentry.Log4Net.Tests/Sentry.Log4Net.Tests.csproj b/test/Sentry.Log4Net.Tests/Sentry.Log4Net.Tests.csproj index afce541b0e..e0217d4ab3 100644 --- a/test/Sentry.Log4Net.Tests/Sentry.Log4Net.Tests.csproj +++ b/test/Sentry.Log4Net.Tests/Sentry.Log4Net.Tests.csproj @@ -19,4 +19,10 @@ + + + SentryAppenderTests.cs + + + diff --git a/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs b/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs new file mode 100644 index 0000000000..4dab5475dc --- /dev/null +++ b/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs @@ -0,0 +1,203 @@ +#nullable enable + +using log4net.Util; + +namespace Sentry.Log4Net.Tests; + +public partial class SentryAppenderTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DoAppend_StructuredLogging_IsEnabled(bool isEnabled) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = isEnabled; + + var sut = _fixture.GetSut(); + + sut.DoAppend(CreateLoggingEvent(Level.Info, "Message")); + + capturer.Logs.Should().HaveCount(isEnabled ? 1 : 0); + } + + public static TheoryData LogLevelData => new() + { + { Level.All, SentryLogLevel.Trace }, + { Level.Finest, SentryLogLevel.Trace }, + { Level.Verbose, SentryLogLevel.Trace }, + { Level.Finer, SentryLogLevel.Trace }, + { Level.Trace, SentryLogLevel.Trace }, + { Level.Fine, SentryLogLevel.Debug }, + { Level.Debug, SentryLogLevel.Debug }, + { Level.Info, SentryLogLevel.Info }, + { Level.Notice, SentryLogLevel.Info }, + { Level.Warn, SentryLogLevel.Warning }, + { Level.Error, SentryLogLevel.Error }, + { Level.Severe, SentryLogLevel.Error }, + { Level.Critical, SentryLogLevel.Error }, + { Level.Alert, SentryLogLevel.Error }, + { Level.Fatal, SentryLogLevel.Fatal }, + { Level.Emergency, SentryLogLevel.Fatal }, + { Level.Log4Net_Debug, SentryLogLevel.Fatal }, + { new Level(0, "DEFAULT"), SentryLogLevel.Trace }, + { new Level(-1, "CUSTOM"), SentryLogLevel.Trace }, + }; + + [Theory] + [MemberData(nameof(LogLevelData))] + public void DoAppend_StructuredLogging_LogLevel(Level level, SentryLogLevel expected) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + + sut.DoAppend(CreateLoggingEvent(level, "Message")); + + capturer.Logs.Should().ContainSingle().Which.Level.Should().Be(expected); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DoAppend_StructuredLogging_LogEvent(bool withActiveSpan) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + _fixture.Options.Environment = "test-environment"; + _fixture.Options.Release = "test-release"; + + if (withActiveSpan) + { + var span = Substitute.For(); + span.TraceId.Returns(SentryId.Create()); + span.SpanId.Returns(SpanId.Create()); + _fixture.Hub.GetSpan().Returns(span); + } + else + { + _fixture.Hub.GetSpan().Returns((ISpan?)null); + } + + var sut = _fixture.GetSut(); + ThreadContext.Properties["Text-Property"] = "4"; + ThreadContext.Properties["Number-Property"] = 4; + ThreadContext.Properties["Collection-Property"] = new[] { 3, 4, 5 }; + ThreadContext.Properties["Object-Property"] = (Number: 4, Text: "4"); + + sut.DoAppend(CreateLoggingEvent(Level.Info, "{0}, {1}, {2}", [0, 1, 2])); + + var log = capturer.Logs.Should().ContainSingle().Which; + log.Timestamp.Should().BeOnOrBefore(DateTimeOffset.Now); + log.TraceId.Should().Be(withActiveSpan ? _fixture.Hub.GetSpan()!.TraceId : _fixture.Scope.PropagationContext.TraceId); + log.Level.Should().Be(SentryLogLevel.Info); + log.Message.Should().Be("0, 1, 2"); + log.Template.Should().BeNull(); + log.Parameters.Should().BeEmpty(); + log.SpanId.Should().Be(withActiveSpan ? _fixture.Hub.GetSpan()!.SpanId : null); + + log.TryGetAttribute("sentry.environment", out object? environment).Should().BeTrue(); + environment.Should().Be("test-environment"); + log.TryGetAttribute("sentry.release", out object? release).Should().BeTrue(); + release.Should().Be("test-release"); + log.TryGetAttribute("sentry.origin", out object? origin).Should().BeTrue(); + origin.Should().Be("auto.log.log4net"); + log.TryGetAttribute("sentry.sdk.name", out object? sdkName).Should().BeTrue(); + sdkName.Should().Be(SentryAppender.SdkName); + log.TryGetAttribute("sentry.sdk.version", out object? sdkVersion).Should().BeTrue(); + sdkVersion.Should().Be(SentryAppender.NameAndVersion.Version); + + log.TryGetAttribute("property.Text-Property", out object? text).Should().BeTrue(); + text.Should().Be("4"); + log.TryGetAttribute("property.Number-Property", out object? number).Should().BeTrue(); + number.Should().Be(4); + log.TryGetAttribute("property.Collection-Property", out object? collection).Should().BeTrue(); + collection.Should().BeEquivalentTo(new[] { 3, 4, 5 }); + log.TryGetAttribute("property.Object-Property", out object? obj).Should().BeTrue(); + obj.Should().Be((Number: 4, Text: "4")); + } + + [Fact] + public void DoAppend_StructuredLogging_Properties() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + + LoggingEventData data = new() + { + LoggerName = "TestLogger", + Level = Level.Info, + Message = "Test Message", + ThreadName = "1", + LocationInfo = new LocationInfo(null), + UserName = "TestUser", + Identity = "TestIdentity", + ExceptionString = "Exception", + Domain = "TestDomain", + Properties = new PropertiesDictionary(), + TimeStampUtc = DateTime.UtcNow, + }; + LoggingEvent loggingEvent = new(data); + sut.DoAppend(loggingEvent); + + var log = capturer.Logs.Should().ContainSingle().Which; + log.Level.Should().Be(SentryLogLevel.Info); + log.Message.Should().Be("Test Message"); + + //TODO: assert Count/Length of Attributes + //requires: https://github.com/getsentry/sentry-dotnet/pull/4936 + //should not contain "log4net:.." properties + } + + [Fact] + public void DoAppend_StructuredLoggingWithException_NoBreadcrumb() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + sut.MinimumEventLevel = Level.Error; + + sut.DoAppend(CreateLoggingEvent(Level.Error, "Message", new Exception("expected message"))); + + capturer.Logs.Should().ContainSingle().Which.Message.Should().Be("Message"); + _fixture.Scope.Breadcrumbs.Should().BeEmpty(); + _ = _fixture.Hub.Received(1).CaptureEvent(Arg.Any()); + } + + [Fact] + public void DoAppend_StructuredLoggingWithoutException_LeavesBreadcrumb() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + sut.MinimumEventLevel = Level.Fatal; + + sut.DoAppend(CreateLoggingEvent(Level.Error, "Message")); + + capturer.Logs.Should().ContainSingle().Which.Message.Should().Be("Message"); + _fixture.Scope.Breadcrumbs.Should().ContainSingle().Which.Message.Should().Be("Message"); + _ = _fixture.Hub.Received(0).CaptureEvent(Arg.Any()); + } + + private static LoggingEvent CreateLoggingEvent(Level level, string message, Exception? exception = null) + { + return new LoggingEvent(null, null, "TestLogger", level, message, exception); + } + + private static LoggingEvent CreateLoggingEvent(Level level, string format, object[] args) + { + var message = new SystemStringFormat(CultureInfo.InvariantCulture, format, args); + return new LoggingEvent(null, null, "TestLogger", level, message, null); + } +} diff --git a/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs b/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs index 531bf178f6..d42a3e9fc1 100644 --- a/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs +++ b/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs @@ -1,8 +1,6 @@ -using log4net.Util; - namespace Sentry.Log4Net.Tests; -public class SentryAppenderTests : IDisposable +public partial class SentryAppenderTests : IDisposable { private class Fixture { @@ -389,192 +387,6 @@ public void DoAppend_AboveMinimumEventLevel_AddsEvent() .CaptureEvent(Arg.Is(e => e.Message.Message == expectedMessage)); } -#nullable enable - [Theory] - [InlineData(false)] - [InlineData(true)] - public void DoAppend_StructuredLogging_IsEnabled(bool isEnabled) - { - InMemorySentryStructuredLogger capturer = new(); - _fixture.Hub.Logger.Returns(capturer); - _fixture.Options.EnableLogs = isEnabled; - - var sut = _fixture.GetSut(); - - sut.DoAppend(CreateLoggingEvent(Level.Info, "Message")); - - capturer.Logs.Should().HaveCount(isEnabled ? 1 : 0); - } - - public static TheoryData LogLevelData => new() - { - { Level.All, SentryLogLevel.Trace }, - { Level.Finest, SentryLogLevel.Trace }, - { Level.Verbose, SentryLogLevel.Trace }, - { Level.Finer, SentryLogLevel.Trace }, - { Level.Trace, SentryLogLevel.Trace }, - { Level.Fine, SentryLogLevel.Debug }, - { Level.Debug, SentryLogLevel.Debug }, - { Level.Info, SentryLogLevel.Info }, - { Level.Notice, SentryLogLevel.Info }, - { Level.Warn, SentryLogLevel.Warning }, - { Level.Error, SentryLogLevel.Error }, - { Level.Severe, SentryLogLevel.Error }, - { Level.Critical, SentryLogLevel.Error }, - { Level.Alert, SentryLogLevel.Error }, - { Level.Fatal, SentryLogLevel.Fatal }, - { Level.Emergency, SentryLogLevel.Fatal }, - { Level.Log4Net_Debug, SentryLogLevel.Fatal }, - { new Level(0, "DEFAULT"), SentryLogLevel.Trace }, - { new Level(-1, "CUSTOM"), SentryLogLevel.Trace }, - }; - - [Theory] - [MemberData(nameof(LogLevelData))] - public void DoAppend_StructuredLogging_LogLevel(Level level, SentryLogLevel expected) - { - InMemorySentryStructuredLogger capturer = new(); - _fixture.Hub.Logger.Returns(capturer); - _fixture.Options.EnableLogs = true; - - var sut = _fixture.GetSut(); - - sut.DoAppend(CreateLoggingEvent(level, "Message")); - - capturer.Logs.Should().ContainSingle().Which.Level.Should().Be(expected); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void DoAppend_StructuredLogging_LogEvent(bool withActiveSpan) - { - InMemorySentryStructuredLogger capturer = new(); - _fixture.Hub.Logger.Returns(capturer); - _fixture.Options.EnableLogs = true; - _fixture.Options.Environment = "test-environment"; - _fixture.Options.Release = "test-release"; - - if (withActiveSpan) - { - var span = Substitute.For(); - span.TraceId.Returns(SentryId.Create()); - span.SpanId.Returns(SpanId.Create()); - _fixture.Hub.GetSpan().Returns(span); - } - else - { - _fixture.Hub.GetSpan().Returns((ISpan?)null); - } - - var sut = _fixture.GetSut(); - ThreadContext.Properties["Text-Property"] = "4"; - ThreadContext.Properties["Number-Property"] = 4; - ThreadContext.Properties["Collection-Property"] = new[] { 3, 4, 5 }; - ThreadContext.Properties["Object-Property"] = (Number: 4, Text: "4"); - - sut.DoAppend(CreateLoggingEvent(Level.Info, "{0}, {1}, {2}", [0, 1, 2])); - - var log = capturer.Logs.Should().ContainSingle().Which; - log.Timestamp.Should().BeOnOrBefore(DateTimeOffset.Now); - log.TraceId.Should().Be(withActiveSpan ? _fixture.Hub.GetSpan()!.TraceId : _fixture.Scope.PropagationContext.TraceId); - log.Level.Should().Be(SentryLogLevel.Info); - log.Message.Should().Be("0, 1, 2"); - log.Template.Should().BeNull(); - log.Parameters.Should().BeEmpty(); - log.SpanId.Should().Be(withActiveSpan ? _fixture.Hub.GetSpan()!.SpanId : null); - - log.TryGetAttribute("sentry.environment", out object? environment).Should().BeTrue(); - environment.Should().Be("test-environment"); - log.TryGetAttribute("sentry.release", out object? release).Should().BeTrue(); - release.Should().Be("test-release"); - log.TryGetAttribute("sentry.origin", out object? origin).Should().BeTrue(); - origin.Should().Be("auto.log.log4net"); - log.TryGetAttribute("sentry.sdk.name", out object? sdkName).Should().BeTrue(); - sdkName.Should().Be(SentryAppender.SdkName); - log.TryGetAttribute("sentry.sdk.version", out object? sdkVersion).Should().BeTrue(); - sdkVersion.Should().Be(SentryAppender.NameAndVersion.Version); - - log.TryGetAttribute("property.Text-Property", out object? text).Should().BeTrue(); - text.Should().Be("4"); - log.TryGetAttribute("property.Number-Property", out object? number).Should().BeTrue(); - number.Should().Be(4); - log.TryGetAttribute("property.Collection-Property", out object? collection).Should().BeTrue(); - collection.Should().BeEquivalentTo(new[] { 3, 4, 5 }); - log.TryGetAttribute("property.Object-Property", out object? obj).Should().BeTrue(); - obj.Should().Be((Number: 4, Text: "4")); - } - - [Fact] - public void DoAppend_StructuredLogging_Properties() - { - InMemorySentryStructuredLogger capturer = new(); - _fixture.Hub.Logger.Returns(capturer); - _fixture.Options.EnableLogs = true; - - var sut = _fixture.GetSut(); - - LoggingEventData data = new() - { - LoggerName = "TestLogger", - Level = Level.Info, - Message = "Test Message", - ThreadName = "1", - LocationInfo = new LocationInfo(null), - UserName = "TestUser", - Identity = "TestIdentity", - ExceptionString = "Exception", - Domain = "TestDomain", - Properties = new PropertiesDictionary(), - TimeStampUtc = DateTime.UtcNow, - }; - LoggingEvent loggingEvent = new(data); - sut.DoAppend(loggingEvent); - - var log = capturer.Logs.Should().ContainSingle().Which; - log.Level.Should().Be(SentryLogLevel.Info); - log.Message.Should().Be("Test Message"); - - //TODO: assert Count/Length of Attributes - //requires: https://github.com/getsentry/sentry-dotnet/pull/4936 - //should not contain "log4net:.." properties - } - - [Fact] - public void DoAppend_StructuredLoggingWithException_NoBreadcrumb() - { - InMemorySentryStructuredLogger capturer = new(); - _fixture.Hub.Logger.Returns(capturer); - _fixture.Options.EnableLogs = true; - - var sut = _fixture.GetSut(); - sut.MinimumEventLevel = Level.Error; - - sut.DoAppend(CreateLoggingEvent(Level.Error, "Message", new Exception("expected message"))); - - capturer.Logs.Should().ContainSingle().Which.Message.Should().Be("Message"); - _fixture.Scope.Breadcrumbs.Should().BeEmpty(); - _ = _fixture.Hub.Received(1).CaptureEvent(Arg.Any()); - } - - [Fact] - public void DoAppend_StructuredLoggingWithoutException_LeavesBreadcrumb() - { - InMemorySentryStructuredLogger capturer = new(); - _fixture.Hub.Logger.Returns(capturer); - _fixture.Options.EnableLogs = true; - - var sut = _fixture.GetSut(); - sut.MinimumEventLevel = Level.Fatal; - - sut.DoAppend(CreateLoggingEvent(Level.Error, "Message")); - - capturer.Logs.Should().ContainSingle().Which.Message.Should().Be("Message"); - _fixture.Scope.Breadcrumbs.Should().ContainSingle().Which.Message.Should().Be("Message"); - _ = _fixture.Hub.Received(0).CaptureEvent(Arg.Any()); - } -#nullable restore - [Fact] public void Close_DisposesSdk() { @@ -591,17 +403,4 @@ public void Close_DisposesSdk() _fixture.SdkDisposeHandle.Received(1).Dispose(); } - -#nullable enable - private static LoggingEvent CreateLoggingEvent(Level level, string message, Exception? exception = null) - { - return new LoggingEvent(null, null, "TestLogger", level, message, exception); - } - - private static LoggingEvent CreateLoggingEvent(Level level, string format, object[] args) - { - var message = new SystemStringFormat(CultureInfo.InvariantCulture, format, args); - return new LoggingEvent(null, null, "TestLogger", level, message, null); - } -#nullable restore } From ca31e0be68487350f9293b43c8ab9a4ddcae50db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20P=C3=B6lz?= <38893694+Flash0ver@users.noreply.github.com> Date: Mon, 18 May 2026 13:18:06 +0200 Subject: [PATCH 06/16] test: add Properties-to-Attributes tests --- .../SentryAppender.Structured.cs | 2 +- .../SentryAppenderTests.Structured.cs | 39 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/Sentry.Log4Net/SentryAppender.Structured.cs b/src/Sentry.Log4Net/SentryAppender.Structured.cs index a17c0baa3d..92ec503c16 100644 --- a/src/Sentry.Log4Net/SentryAppender.Structured.cs +++ b/src/Sentry.Log4Net/SentryAppender.Structured.cs @@ -20,7 +20,7 @@ private static void CaptureStructuredLog(IHub hub, SentryOptions options, Loggin { if (property is DictionaryEntry { Key: string key, Value: { } value }) { - if (key.Length != 0 && !key.StartsWith("log4net:", StringComparison.OrdinalIgnoreCase)) + if (key.Length != 0 && !key.StartsWith("log4net:", StringComparison.OrdinalIgnoreCase) && !Guid.TryParse(key, out _)) { log.SetAttribute($"property.{key}", value); } diff --git a/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs b/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs index 4dab5475dc..3dcb430b7e 100644 --- a/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs +++ b/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs @@ -144,16 +144,49 @@ public void DoAppend_StructuredLogging_Properties() Properties = new PropertiesDictionary(), TimeStampUtc = DateTime.UtcNow, }; + data.Properties[""] = "empty"; + data.Properties["test.property.key"] = "test-property-value"; LoggingEvent loggingEvent = new(data); sut.DoAppend(loggingEvent); var log = capturer.Logs.Should().ContainSingle().Which; log.Level.Should().Be(SentryLogLevel.Info); log.Message.Should().Be("Test Message"); + log.Attributes.Should().NotContain(attribute => attribute.Key.Contains("log4net:")); + log.Attributes.Should().ContainSingle(attribute => attribute.Key.StartsWith("property.")); + log.Attributes.Should().Contain(attribute => attribute.Key.Equals("property.test.property.key")).Which.Value.Value.Should().Be("test-property-value"); + } + + [Fact] + public void DoAppend_StructuredLogging_DefaultProperties() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + + LoggingEventData data = new() + { + LoggerName = "TestLogger", + Level = Level.Info, + Message = "Test Message", + ThreadName = "1", + LocationInfo = new LocationInfo(null), + UserName = "TestUser", + Identity = "TestIdentity", + ExceptionString = "Exception", + Domain = "TestDomain", + TimeStampUtc = DateTime.UtcNow, + }; + LoggingEvent loggingEvent = new(data); + sut.DoAppend(loggingEvent); - //TODO: assert Count/Length of Attributes - //requires: https://github.com/getsentry/sentry-dotnet/pull/4936 - //should not contain "log4net:.." properties + var log = capturer.Logs.Should().ContainSingle().Which; + log.Level.Should().Be(SentryLogLevel.Info); + log.Message.Should().Be("Test Message"); + log.Attributes.Should().NotContain(attribute => attribute.Key.Contains("log4net:")); + log.Attributes.Should().NotContain(attribute => attribute.Key.StartsWith("property.")); } [Fact] From 441b65511b5e58e2bf40f0b38b27dd54d54c97cf Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Wed, 15 Jul 2026 19:17:09 +1200 Subject: [PATCH 07/16] fix(log4net): guard null RenderedMessage and clean up ThreadContext in tests - Fall back to string.Empty when RenderedMessage is null, matching the existing non-structured path (review r3258513795). - Clear ThreadContext.Properties in test teardown so thread-static entries don't leak into subsequent tests (review r3258530109). - Adapt CaptureStructuredLog to the SetDefaultAttributes(options, scope, sdk) signature introduced on main, passing the active scope for enrichment. Co-Authored-By: Claude Opus 4.8 --- src/Sentry.Log4Net/SentryAppender.Structured.cs | 6 ++++-- test/Sentry.Log4Net.Tests/SentryAppenderTests.cs | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Sentry.Log4Net/SentryAppender.Structured.cs b/src/Sentry.Log4Net/SentryAppender.Structured.cs index 92ec503c16..18ab50e655 100644 --- a/src/Sentry.Log4Net/SentryAppender.Structured.cs +++ b/src/Sentry.Log4Net/SentryAppender.Structured.cs @@ -11,9 +11,11 @@ private static void CaptureStructuredLog(IHub hub, SentryOptions options, Loggin const string? template = null; // cannot get format-string from `log4net.Util.SystemStringFormat` via `LoggingEvent.MessageObject` var parameters = ImmutableArray>.Empty; // cannot get arguments from `log4net.Util.SystemStringFormat` via `LoggingEvent.MessageObject` - var log = SentryLog.Create(hub, timestamp, level.Value, loggingEvent.RenderedMessage, template, parameters); + var message = !string.IsNullOrWhiteSpace(loggingEvent.RenderedMessage) ? loggingEvent.RenderedMessage : string.Empty; + var log = SentryLog.Create(hub, timestamp, level.Value, message, template, parameters); - log.SetDefaultAttributes(options, Sdk); + var scope = hub.GetScope(); + log.SetDefaultAttributes(options, scope, Sdk); log.SetOrigin("auto.log.log4net"); foreach (var property in loggingEvent.GetProperties()) diff --git a/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs b/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs index d42a3e9fc1..cfcad5c7c9 100644 --- a/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs +++ b/test/Sentry.Log4Net.Tests/SentryAppenderTests.cs @@ -44,6 +44,8 @@ public SentryAppender GetSut() public void Dispose() { SentryClientExtensions.SentryOptionsForTestingOnly = null; + // ThreadContext.Properties is thread-static and can leak into subsequent tests running on the same thread. + ThreadContext.Properties.Clear(); } [Fact] From 0a7f38855341daada9821e95e0ce12e54059afe7 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Wed, 15 Jul 2026 19:24:56 +1200 Subject: [PATCH 08/16] test(log4net): simplify structured log assertions with Sentry.Testing extensions Use the SentryAttributes.ShouldContain extensions added in #4936 instead of the verbose TryGetAttribute/Should().Be pairs (review r3258494145). Grants Sentry.Log4Net.Tests access to Sentry.Testing internals. The collection assertion keeps BeEquivalentTo since the extension compares by value equality. Co-Authored-By: Claude Opus 4.8 --- .../SentryAppenderTests.Structured.cs | 30 ++++++++----------- test/Sentry.Testing/Sentry.Testing.csproj | 1 + 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs b/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs index 3dcb430b7e..37ea57a8c8 100644 --- a/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs +++ b/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs @@ -1,6 +1,7 @@ #nullable enable using log4net.Util; +using Sentry.Testing; namespace Sentry.Log4Net.Tests; @@ -100,25 +101,18 @@ public void DoAppend_StructuredLogging_LogEvent(bool withActiveSpan) log.Parameters.Should().BeEmpty(); log.SpanId.Should().Be(withActiveSpan ? _fixture.Hub.GetSpan()!.SpanId : null); - log.TryGetAttribute("sentry.environment", out object? environment).Should().BeTrue(); - environment.Should().Be("test-environment"); - log.TryGetAttribute("sentry.release", out object? release).Should().BeTrue(); - release.Should().Be("test-release"); - log.TryGetAttribute("sentry.origin", out object? origin).Should().BeTrue(); - origin.Should().Be("auto.log.log4net"); - log.TryGetAttribute("sentry.sdk.name", out object? sdkName).Should().BeTrue(); - sdkName.Should().Be(SentryAppender.SdkName); - log.TryGetAttribute("sentry.sdk.version", out object? sdkVersion).Should().BeTrue(); - sdkVersion.Should().Be(SentryAppender.NameAndVersion.Version); - - log.TryGetAttribute("property.Text-Property", out object? text).Should().BeTrue(); - text.Should().Be("4"); - log.TryGetAttribute("property.Number-Property", out object? number).Should().BeTrue(); - number.Should().Be(4); + log.Attributes.ShouldContain("sentry.environment", "test-environment"); + log.Attributes.ShouldContain("sentry.release", "test-release"); + log.Attributes.ShouldContain("sentry.origin", "auto.log.log4net"); + log.Attributes.ShouldContain("sentry.sdk.name", SentryAppender.SdkName); + log.Attributes.ShouldContain("sentry.sdk.version", SentryAppender.NameAndVersion.Version); + + log.Attributes.ShouldContain("property.Text-Property", "4"); + log.Attributes.ShouldContain("property.Number-Property", 4); + // Collections are compared by value, so this one keeps BeEquivalentTo rather than the ShouldContain (Be) extension. log.TryGetAttribute("property.Collection-Property", out object? collection).Should().BeTrue(); collection.Should().BeEquivalentTo(new[] { 3, 4, 5 }); - log.TryGetAttribute("property.Object-Property", out object? obj).Should().BeTrue(); - obj.Should().Be((Number: 4, Text: "4")); + log.Attributes.ShouldContain("property.Object-Property", (Number: 4, Text: "4")); } [Fact] @@ -154,7 +148,7 @@ public void DoAppend_StructuredLogging_Properties() log.Message.Should().Be("Test Message"); log.Attributes.Should().NotContain(attribute => attribute.Key.Contains("log4net:")); log.Attributes.Should().ContainSingle(attribute => attribute.Key.StartsWith("property.")); - log.Attributes.Should().Contain(attribute => attribute.Key.Equals("property.test.property.key")).Which.Value.Value.Should().Be("test-property-value"); + log.Attributes.ShouldContain("property.test.property.key", "test-property-value"); } [Fact] diff --git a/test/Sentry.Testing/Sentry.Testing.csproj b/test/Sentry.Testing/Sentry.Testing.csproj index a1576ba0a5..c5ff9a88b4 100644 --- a/test/Sentry.Testing/Sentry.Testing.csproj +++ b/test/Sentry.Testing/Sentry.Testing.csproj @@ -16,6 +16,7 @@ + From 575d2e6ce4b192169013e5361b82b2abddad5a16 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 16 Jul 2026 13:36:52 +1200 Subject: [PATCH 09/16] feat(log4net): map LoggerName to category.name on structured logs Brings the log4net structured-log path in line with the MEL integration, which sets category.name from the logger/category name. Previously the log4net logger name was only surfaced on the legacy SentryEvent.Logger. Co-Authored-By: Claude Opus 4.8 --- src/Sentry.Log4Net/SentryAppender.Structured.cs | 5 +++++ test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs | 1 + 2 files changed, 6 insertions(+) diff --git a/src/Sentry.Log4Net/SentryAppender.Structured.cs b/src/Sentry.Log4Net/SentryAppender.Structured.cs index 18ab50e655..fdd6a1c970 100644 --- a/src/Sentry.Log4Net/SentryAppender.Structured.cs +++ b/src/Sentry.Log4Net/SentryAppender.Structured.cs @@ -18,6 +18,11 @@ private static void CaptureStructuredLog(IHub hub, SentryOptions options, Loggin log.SetDefaultAttributes(options, scope, Sdk); log.SetOrigin("auto.log.log4net"); + if (loggingEvent.LoggerName is { } loggerName) + { + log.SetAttribute("category.name", loggerName); + } + foreach (var property in loggingEvent.GetProperties()) { if (property is DictionaryEntry { Key: string key, Value: { } value }) diff --git a/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs b/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs index 37ea57a8c8..db43de9360 100644 --- a/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs +++ b/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs @@ -106,6 +106,7 @@ public void DoAppend_StructuredLogging_LogEvent(bool withActiveSpan) log.Attributes.ShouldContain("sentry.origin", "auto.log.log4net"); log.Attributes.ShouldContain("sentry.sdk.name", SentryAppender.SdkName); log.Attributes.ShouldContain("sentry.sdk.version", SentryAppender.NameAndVersion.Version); + log.Attributes.ShouldContain("category.name", "TestLogger"); log.Attributes.ShouldContain("property.Text-Property", "4"); log.Attributes.ShouldContain("property.Number-Property", 4); From 56abb5190876224c1be37fd787c01defe0ab1357 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 16 Jul 2026 13:44:54 +1200 Subject: [PATCH 10/16] chore(log4net): drop redundant NETFRAMEWORK guard in sample The sample only targets net481, so the #if NETFRAMEWORK conditional is always true. Remove it and keep the SetPrincipalPolicy call unconditionally. Co-Authored-By: Claude Opus 4.8 --- samples/Sentry.Samples.Log4Net/Program.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/samples/Sentry.Samples.Log4Net/Program.cs b/samples/Sentry.Samples.Log4Net/Program.cs index 4f5cb1297b..f35396bf34 100644 --- a/samples/Sentry.Samples.Log4Net/Program.cs +++ b/samples/Sentry.Samples.Log4Net/Program.cs @@ -10,11 +10,9 @@ internal class Program private static void Main() { -#if NETFRAMEWORK // Set the user running the process the current principal // Appender was configured to send the user with the event AppDomain.CurrentDomain.SetPrincipalPolicy(PrincipalPolicy.WindowsPrincipal); -#endif // The following anonymous object gets serialized and sent with log messages ThreadContext.Properties["inventory"] = new From b919a280d2a3c56d9383d75fcc6737ef5dca02b9 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 16 Jul 2026 13:48:08 +1200 Subject: [PATCH 11/16] refactor(logs): inline SentryLog.Create instead of a separate partial file Move the Create factory method into SentryLog.cs, drop the now-unneeded partial modifier, and remove the SentryLog.Factory.cs DependentUpon entry from the csproj. A single method doesn't warrant its own file. Co-Authored-By: Claude Opus 4.8 --- src/Sentry/Sentry.csproj | 3 --- src/Sentry/SentryLog.Factory.cs | 18 ------------------ src/Sentry/SentryLog.cs | 16 +++++++++++++++- 3 files changed, 15 insertions(+), 22 deletions(-) delete mode 100644 src/Sentry/SentryLog.Factory.cs diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index 725e93d46f..f0f21c7048 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -194,9 +194,6 @@ MeasurementUnit.cs - - SentryLog.cs - SentryMetric.cs diff --git a/src/Sentry/SentryLog.Factory.cs b/src/Sentry/SentryLog.Factory.cs deleted file mode 100644 index bcb7149bba..0000000000 --- a/src/Sentry/SentryLog.Factory.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Sentry; - -public sealed partial class SentryLog -{ - internal static SentryLog Create(IHub hub, DateTimeOffset timestamp, SentryLogLevel level, string message, string? template, ImmutableArray> parameters) - { - hub.GetTraceIdAndSpanId(out var traceId, out var spanId); - - SentryLog log = new(timestamp, traceId, level, message) - { - Template = template, - Parameters = parameters, - SpanId = spanId, - }; - - return log; - } -} diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index ac3c1b4ba7..95976ba6f1 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -12,7 +12,7 @@ namespace Sentry; /// Sentry .NET SDK Docs: . /// [DebuggerDisplay(@"SentryLog \{ Level = {Level}, Message = '{Message}' \}")] -public sealed partial class SentryLog +public sealed class SentryLog { [SetsRequiredMembers] internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentryLogLevel level, string message) @@ -27,6 +27,20 @@ internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentryLogLevel le Parameters = ImmutableArray>.Empty; } + internal static SentryLog Create(IHub hub, DateTimeOffset timestamp, SentryLogLevel level, string message, string? template, ImmutableArray> parameters) + { + hub.GetTraceIdAndSpanId(out var traceId, out var spanId); + + SentryLog log = new(timestamp, traceId, level, message) + { + Template = template, + Parameters = parameters, + SpanId = spanId, + }; + + return log; + } + /// /// The timestamp of the log. /// From 1f3468cc72dca056702b7a5f1cdcd4f327ab0c06 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 16 Jul 2026 13:50:53 +1200 Subject: [PATCH 12/16] refactor(log4net): flatten CaptureStructuredLog with an early return Invert the level check into a pattern-matched guard clause that returns early, removing a level of nesting and folding the null check into the assignment. Co-Authored-By: Claude Opus 4.8 --- .../SentryAppender.Structured.cs | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/src/Sentry.Log4Net/SentryAppender.Structured.cs b/src/Sentry.Log4Net/SentryAppender.Structured.cs index fdd6a1c970..a3a40c2239 100644 --- a/src/Sentry.Log4Net/SentryAppender.Structured.cs +++ b/src/Sentry.Log4Net/SentryAppender.Structured.cs @@ -4,37 +4,38 @@ public partial class SentryAppender { private static void CaptureStructuredLog(IHub hub, SentryOptions options, LoggingEvent loggingEvent) { - var level = loggingEvent.ToSentryLogLevel(); - if (level.HasValue) + if (loggingEvent.ToSentryLogLevel() is not { } level) { - DateTimeOffset timestamp = new(loggingEvent.TimeStampUtc); - const string? template = null; // cannot get format-string from `log4net.Util.SystemStringFormat` via `LoggingEvent.MessageObject` - var parameters = ImmutableArray>.Empty; // cannot get arguments from `log4net.Util.SystemStringFormat` via `LoggingEvent.MessageObject` + return; + } - var message = !string.IsNullOrWhiteSpace(loggingEvent.RenderedMessage) ? loggingEvent.RenderedMessage : string.Empty; - var log = SentryLog.Create(hub, timestamp, level.Value, message, template, parameters); + DateTimeOffset timestamp = new(loggingEvent.TimeStampUtc); + const string? template = null; // cannot get format-string from `log4net.Util.SystemStringFormat` via `LoggingEvent.MessageObject` + var parameters = ImmutableArray>.Empty; // cannot get arguments from `log4net.Util.SystemStringFormat` via `LoggingEvent.MessageObject` - var scope = hub.GetScope(); - log.SetDefaultAttributes(options, scope, Sdk); - log.SetOrigin("auto.log.log4net"); + var message = !string.IsNullOrWhiteSpace(loggingEvent.RenderedMessage) ? loggingEvent.RenderedMessage : string.Empty; + var log = SentryLog.Create(hub, timestamp, level, message, template, parameters); - if (loggingEvent.LoggerName is { } loggerName) - { - log.SetAttribute("category.name", loggerName); - } + var scope = hub.GetScope(); + log.SetDefaultAttributes(options, scope, Sdk); + log.SetOrigin("auto.log.log4net"); - foreach (var property in loggingEvent.GetProperties()) + if (loggingEvent.LoggerName is { } loggerName) + { + log.SetAttribute("category.name", loggerName); + } + + foreach (var property in loggingEvent.GetProperties()) + { + if (property is DictionaryEntry { Key: string key, Value: { } value }) { - if (property is DictionaryEntry { Key: string key, Value: { } value }) + if (key.Length != 0 && !key.StartsWith("log4net:", StringComparison.OrdinalIgnoreCase) && !Guid.TryParse(key, out _)) { - if (key.Length != 0 && !key.StartsWith("log4net:", StringComparison.OrdinalIgnoreCase) && !Guid.TryParse(key, out _)) - { - log.SetAttribute($"property.{key}", value); - } + log.SetAttribute($"property.{key}", value); } } - - hub.Logger.CaptureLog(log); } + + hub.Logger.CaptureLog(log); } } From c5d512d3a12f6c54d5db29d67cc302e08a05e32b Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 16 Jul 2026 14:04:35 +1200 Subject: [PATCH 13/16] fix(log4net): guard against null GetProperties() in structured logging Matches the sibling GetLoggingEventProperties helper so structured logging doesn't rely on log4net's GetProperties() never returning null. Co-Authored-By: Claude Opus 4.8 --- src/Sentry.Log4Net/SentryAppender.Structured.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Sentry.Log4Net/SentryAppender.Structured.cs b/src/Sentry.Log4Net/SentryAppender.Structured.cs index a3a40c2239..465f26761b 100644 --- a/src/Sentry.Log4Net/SentryAppender.Structured.cs +++ b/src/Sentry.Log4Net/SentryAppender.Structured.cs @@ -25,13 +25,18 @@ private static void CaptureStructuredLog(IHub hub, SentryOptions options, Loggin log.SetAttribute("category.name", loggerName); } - foreach (var property in loggingEvent.GetProperties()) + // GetProperties() is non-null in current log4net, but the sibling GetLoggingEventProperties guards + // against null, so we do too rather than rely on log4net's internals. + if (loggingEvent.GetProperties() is { } properties) { - if (property is DictionaryEntry { Key: string key, Value: { } value }) + foreach (var property in properties) { - if (key.Length != 0 && !key.StartsWith("log4net:", StringComparison.OrdinalIgnoreCase) && !Guid.TryParse(key, out _)) + if (property is DictionaryEntry { Key: string key, Value: { } value }) { - log.SetAttribute($"property.{key}", value); + if (key.Length != 0 && !key.StartsWith("log4net:", StringComparison.OrdinalIgnoreCase) && !Guid.TryParse(key, out _)) + { + log.SetAttribute($"property.{key}", value); + } } } } From 13260a45ebc16537515fceb5c3076d8202083856 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 16 Jul 2026 14:12:57 +1200 Subject: [PATCH 14/16] Refactor Appender to avoid a huge Append method --- src/Sentry.Log4Net/SentryAppender.cs | 46 ++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/src/Sentry.Log4Net/SentryAppender.cs b/src/Sentry.Log4Net/SentryAppender.cs index a6381f4c0f..62434f8ba2 100644 --- a/src/Sentry.Log4Net/SentryAppender.cs +++ b/src/Sentry.Log4Net/SentryAppender.cs @@ -90,27 +90,34 @@ protected override void Append(LoggingEvent loggingEvent) } } - var options = _hub.GetSentryOptions(); - if (options is { EnableLogs: true }) - { - CaptureStructuredLog(_hub, options, loggingEvent); - } - var exception = loggingEvent.ExceptionObject ?? loggingEvent.MessageObject as Exception; if (MinimumEventLevel is not null && loggingEvent.Level < MinimumEventLevel) { - var message = !string.IsNullOrWhiteSpace(loggingEvent.RenderedMessage) ? loggingEvent.RenderedMessage : string.Empty; - var category = loggingEvent.LoggerName; - var level = loggingEvent.ToBreadcrumbLevel(); - IDictionary data = GetLoggingEventProperties(loggingEvent) - .Where(kvp => kvp.Value != null) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value!.ToString() ?? ""); + AddBreadcrumbFromLoggingEvent(loggingEvent); + return; + } + + CreateSentryEvent(loggingEvent, exception); - _hub.AddBreadcrumb(message, category, type: null, data, level ?? default); + var options = _hub.GetSentryOptions(); + if (options is not { EnableLogs: true }) + { return; } + try + { + CaptureStructuredLog(_hub, options, loggingEvent); + } + catch (Exception ex) + { + options.DiagnosticLogger?.LogError(ex, "Failed to capture structured log. The log will be dropped."); + } + } + + private void CreateSentryEvent(LoggingEvent loggingEvent, Exception? exception) + { var evt = new SentryEvent(exception) { Logger = loggingEvent.LoggerName, @@ -151,6 +158,19 @@ protected override void Append(LoggingEvent loggingEvent) _hub.CaptureEvent(evt); } + private void AddBreadcrumbFromLoggingEvent(LoggingEvent loggingEvent) + { + var message = !string.IsNullOrWhiteSpace(loggingEvent.RenderedMessage) ? loggingEvent.RenderedMessage : string.Empty; + var category = loggingEvent.LoggerName; + var level = loggingEvent.ToBreadcrumbLevel(); + IDictionary data = GetLoggingEventProperties(loggingEvent) + .Where(kvp => kvp.Value != null) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value!.ToString() ?? ""); + + _hub.AddBreadcrumb(message, category, type: null, data, level ?? default); + return; + } + private static IEnumerable> GetLoggingEventProperties(LoggingEvent loggingEvent) { var properties = loggingEvent.GetProperties(); From 2b315340b77b58bb7304407546bb01e92389b7fc Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 16 Jul 2026 15:30:55 +1200 Subject: [PATCH 15/16] fix(log4net): honor appender settings and don't skip logs below MinimumEventLevel Two structured-logging fixes: - Capture structured logs for every event again. The Append refactor in 13260a4 moved the EnableLogs block after the breadcrumb early-return, so events below MinimumEventLevel no longer produced structured logs. Move the capture ahead of the breadcrumb/event branching, restoring the original behavior (covered by DoAppend_StructuredLoggingWithoutException_LeavesBreadcrumb). - Apply the appender's Environment and SendIdentity settings to structured logs, overriding the scope/options defaults, to match the SentryEvent path. These are per-appender, opt-in settings, so SendIdentity mirrors the event path and is not additionally gated on SendDefaultPii. Co-Authored-By: Claude Opus 4.8 --- .../SentryAppender.Structured.cs | 13 +++- src/Sentry.Log4Net/SentryAppender.cs | 8 ++- .../SentryAppenderTests.Structured.cs | 60 +++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/Sentry.Log4Net/SentryAppender.Structured.cs b/src/Sentry.Log4Net/SentryAppender.Structured.cs index 465f26761b..f0230dafc5 100644 --- a/src/Sentry.Log4Net/SentryAppender.Structured.cs +++ b/src/Sentry.Log4Net/SentryAppender.Structured.cs @@ -2,7 +2,7 @@ namespace Sentry.Log4Net; public partial class SentryAppender { - private static void CaptureStructuredLog(IHub hub, SentryOptions options, LoggingEvent loggingEvent) + private static void CaptureStructuredLog(IHub hub, SentryOptions options, LoggingEvent loggingEvent, string? environment, bool sendIdentity) { if (loggingEvent.ToSentryLogLevel() is not { } level) { @@ -20,6 +20,17 @@ private static void CaptureStructuredLog(IHub hub, SentryOptions options, Loggin log.SetDefaultAttributes(options, scope, Sdk); log.SetOrigin("auto.log.log4net"); + // Honor the appender-level settings, overriding the scope/options defaults, to match the SentryEvent path. + if (!string.IsNullOrWhiteSpace(environment)) + { + log.SetAttribute("sentry.environment", environment!); + } + + if (sendIdentity && !string.IsNullOrEmpty(loggingEvent.Identity)) + { + log.SetAttribute("user.id", loggingEvent.Identity); + } + if (loggingEvent.LoggerName is { } loggerName) { log.SetAttribute("category.name", loggerName); diff --git a/src/Sentry.Log4Net/SentryAppender.cs b/src/Sentry.Log4Net/SentryAppender.cs index 62434f8ba2..9aa1948236 100644 --- a/src/Sentry.Log4Net/SentryAppender.cs +++ b/src/Sentry.Log4Net/SentryAppender.cs @@ -90,6 +90,9 @@ protected override void Append(LoggingEvent loggingEvent) } } + // Structured logs are captured for every event, independent of the breadcrumb/event branching below. + CaptureStructuredLogIfEnabled(loggingEvent); + var exception = loggingEvent.ExceptionObject ?? loggingEvent.MessageObject as Exception; if (MinimumEventLevel is not null && loggingEvent.Level < MinimumEventLevel) @@ -99,7 +102,10 @@ protected override void Append(LoggingEvent loggingEvent) } CreateSentryEvent(loggingEvent, exception); + } + private void CaptureStructuredLogIfEnabled(LoggingEvent loggingEvent) + { var options = _hub.GetSentryOptions(); if (options is not { EnableLogs: true }) { @@ -108,7 +114,7 @@ protected override void Append(LoggingEvent loggingEvent) try { - CaptureStructuredLog(_hub, options, loggingEvent); + CaptureStructuredLog(_hub, options, loggingEvent, Environment, SendIdentity); } catch (Exception ex) { diff --git a/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs b/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs index db43de9360..8049b6e8fd 100644 --- a/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs +++ b/test/Sentry.Log4Net.Tests/SentryAppenderTests.Structured.cs @@ -218,6 +218,66 @@ public void DoAppend_StructuredLoggingWithoutException_LeavesBreadcrumb() _ = _fixture.Hub.Received(0).CaptureEvent(Arg.Any()); } + [Fact] + public void DoAppend_StructuredLogging_ConfiguredEnvironment_OverridesOptions() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + _fixture.Options.Environment = "options-environment"; + + var sut = _fixture.GetSut(); + sut.Environment = "appender-environment"; + + sut.DoAppend(CreateLoggingEvent(Level.Info, "Message")); + + var log = capturer.Logs.Should().ContainSingle().Which; + log.Attributes.ShouldContain("sentry.environment", "appender-environment"); + } + + [Fact] + public void DoAppend_StructuredLogging_SendIdentity_SetsUser() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + sut.SendIdentity = true; + + sut.DoAppend(new LoggingEvent(new LoggingEventData + { + Level = Level.Info, + Message = "Message", + Identity = "TestIdentity", + TimeStampUtc = DateTime.UtcNow, + })); + + var log = capturer.Logs.Should().ContainSingle().Which; + log.Attributes.ShouldContain("user.id", "TestIdentity"); + } + + [Fact] + public void DoAppend_StructuredLogging_SendIdentityDisabled_DoesNotSetUser() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var sut = _fixture.GetSut(); + + sut.DoAppend(new LoggingEvent(new LoggingEventData + { + Level = Level.Info, + Message = "Message", + Identity = "TestIdentity", + TimeStampUtc = DateTime.UtcNow, + })); + + var log = capturer.Logs.Should().ContainSingle().Which; + log.Attributes.ShouldNotContain("user.id"); + } + private static LoggingEvent CreateLoggingEvent(Level level, string message, Exception? exception = null) { return new LoggingEvent(null, null, "TestLogger", level, message, exception); From 4236d5f433b38855657941cdc09b88ffda9340c0 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 16 Jul 2026 19:53:27 +1200 Subject: [PATCH 16/16] Update src/Sentry.Log4Net/SentryAppender.cs --- src/Sentry.Log4Net/SentryAppender.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Sentry.Log4Net/SentryAppender.cs b/src/Sentry.Log4Net/SentryAppender.cs index 9aa1948236..a4bc0367f1 100644 --- a/src/Sentry.Log4Net/SentryAppender.cs +++ b/src/Sentry.Log4Net/SentryAppender.cs @@ -90,7 +90,6 @@ protected override void Append(LoggingEvent loggingEvent) } } - // Structured logs are captured for every event, independent of the breadcrumb/event branching below. CaptureStructuredLogIfEnabled(loggingEvent); var exception = loggingEvent.ExceptionObject ?? loggingEvent.MessageObject as Exception;