diff --git a/samples/Sentry.Samples.NLog/NLog.config b/samples/Sentry.Samples.NLog/NLog.config index 5022aa23bb..8a5d1bae52 100644 --- a/samples/Sentry.Samples.NLog/NLog.config +++ b/samples/Sentry.Samples.NLog/NLog.config @@ -22,7 +22,8 @@ ignoreEventsWithNoException="False" includeEventDataOnBreadcrumbs="False" includeEventPropertiesAsTags="True" - minimumEventLevel="Error"> + minimumEventLevel="Error" + enableLogs="True"> BreadcrumbLevel.Info }; } + + public static SentryLogLevel? ToSentryLogLevel(this LogLevel level) + { + return level.Name switch + { + nameof(LogLevel.Trace) => SentryLogLevel.Trace, + nameof(LogLevel.Debug) => SentryLogLevel.Debug, + nameof(LogLevel.Info) => SentryLogLevel.Info, + nameof(LogLevel.Warn) => SentryLogLevel.Warning, + nameof(LogLevel.Error) => SentryLogLevel.Error, + nameof(LogLevel.Fatal) => SentryLogLevel.Fatal, + nameof(LogLevel.Off) => null, + _ => null, + }; + } } diff --git a/src/Sentry.NLog/Sentry.NLog.csproj b/src/Sentry.NLog/Sentry.NLog.csproj index 85976c7124..66aa9ef4b2 100644 --- a/src/Sentry.NLog/Sentry.NLog.csproj +++ b/src/Sentry.NLog/Sentry.NLog.csproj @@ -35,4 +35,10 @@ + + + SentryTarget.cs + + + diff --git a/src/Sentry.NLog/SentryTarget.Structured.cs b/src/Sentry.NLog/SentryTarget.Structured.cs new file mode 100644 index 0000000000..362e935449 --- /dev/null +++ b/src/Sentry.NLog/SentryTarget.Structured.cs @@ -0,0 +1,72 @@ +namespace Sentry.NLog; + +public sealed partial class SentryTarget +{ + private static void CaptureStructuredLog(IHub hub, SentryOptions options, LogEventInfo logEvent) + { + var level = logEvent.Level.ToSentryLogLevel(); + if (level.HasValue) + { + DateTimeOffset timestamp = new(logEvent.TimeStamp); + GetStructuredLoggingParametersAndAttributes(logEvent, out var parameters, out var attributes); + + var log = SentryLog.Create(hub, timestamp, level.Value, logEvent.FormattedMessage, logEvent.Message, parameters); + + log.SetDefaultAttributes(options, Sdk); + log.SetOrigin("auto.log.nlog"); + + foreach (var attribute in attributes) + { + log.SetAttribute(attribute.Key, attribute.Value); + } + + hub.Logger.CaptureLog(log); + } + } + + private static void GetStructuredLoggingParametersAndAttributes(LogEventInfo logEvent, out ImmutableArray> parameters, out List> attributes) + { + parameters = GetParameters(logEvent, out var parameterNames); + attributes = new List>(); + + if (logEvent.HasProperties) + { + foreach (var property in logEvent.Properties) + { + if (property.Key is string key && !string.IsNullOrWhiteSpace(key) && + property.Value is { } value && + !parameterNames.Contains(key)) + { + attributes.Add(new KeyValuePair($"property.{key}", value)); + } + } + } + } + + private static ImmutableArray> GetParameters(LogEventInfo logEvent, out HashSet parameterNames) + { + var parameters = logEvent.MessageTemplateParameters; + + if (parameters.Count == 0) + { + parameterNames = new HashSet(); + return ImmutableArray>.Empty; + } + +#if NETSTANDARD2_1_OR_GREATER || NET472_OR_GREATER || NETCOREAPP2_0_OR_GREATER + parameterNames = new HashSet(parameters.Count); +#else + parameterNames = new HashSet(); +#endif + + var @params = ImmutableArray.CreateBuilder>(parameters.Count); + + foreach (var parameter in parameters) + { + parameterNames.Add(parameter.Name); + @params.Add(new KeyValuePair(parameter.Name, parameter.Value)); + } + + return @params.DrainToImmutable(); + } +} diff --git a/src/Sentry.NLog/SentryTarget.cs b/src/Sentry.NLog/SentryTarget.cs index b799e525ab..0cdbc80ce9 100644 --- a/src/Sentry.NLog/SentryTarget.cs +++ b/src/Sentry.NLog/SentryTarget.cs @@ -4,7 +4,7 @@ namespace Sentry.NLog; /// Sentry NLog Target. /// [Target("Sentry")] -public sealed class SentryTarget : TargetWithContext +public sealed partial class SentryTarget : TargetWithContext { // For testing: internal Func HubAccessor { get; } @@ -14,6 +14,12 @@ public sealed class SentryTarget : TargetWithContext internal static readonly SdkVersion NameAndVersion = typeof(SentryTarget).Assembly.GetNameAndVersion(); + private static readonly SdkVersion Sdk = new() + { + Name = Constants.SdkName, + Version = NameAndVersion.Version, + }; + internal static readonly string AdditionalGroupingKeyProperty = "AdditionalGroupingKey"; private static readonly string ProtocolPackageName = "nuget:" + NameAndVersion.Name; @@ -129,6 +135,15 @@ public string MinimumBreadcrumbLevel set => Options.MinimumBreadcrumbLevel = LogLevel.FromString(value); } + /// + /// Controls whether logs are generated and sent. + /// + public bool EnableLogs + { + get => Options.EnableLogs; + set => Options.EnableLogs = value; + } + /// /// Whether the NLog integration should initialize the SDK. /// @@ -331,6 +346,11 @@ private void InnerWrite(LogEventInfo logEvent) var shouldOnlyLogExceptions = exception == null && IgnoreEventsWithNoException; var shouldIncludeProperties = ContextProperties?.Count > 0 || ShouldIncludeProperties(logEvent); + if (Options.EnableLogs) + { + CaptureStructuredLog(hub, Options, logEvent); + } + if (logEvent.Level >= Options.MinimumEventLevel && !shouldOnlyLogExceptions) { var formatted = RenderLogEvent(Layout, logEvent); 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.NLog.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 00a36bc53b..9e7456b450 100644 --- a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -59,6 +59,7 @@ namespace Sentry.NLog public NLog.Layouts.Layout? BreadcrumbCategory { get; set; } public NLog.Layouts.Layout? BreadcrumbLayout { get; set; } public NLog.Layouts.Layout? Dsn { get; set; } + public bool EnableLogs { get; set; } public NLog.Layouts.Layout? Environment { get; set; } public int FlushTimeoutSeconds { get; set; } public bool IgnoreEventsWithNoException { get; set; } diff --git a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 00a36bc53b..9e7456b450 100644 --- a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -59,6 +59,7 @@ namespace Sentry.NLog public NLog.Layouts.Layout? BreadcrumbCategory { get; set; } public NLog.Layouts.Layout? BreadcrumbLayout { get; set; } public NLog.Layouts.Layout? Dsn { get; set; } + public bool EnableLogs { get; set; } public NLog.Layouts.Layout? Environment { get; set; } public int FlushTimeoutSeconds { get; set; } public bool IgnoreEventsWithNoException { get; set; } diff --git a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 00a36bc53b..9e7456b450 100644 --- a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -59,6 +59,7 @@ namespace Sentry.NLog public NLog.Layouts.Layout? BreadcrumbCategory { get; set; } public NLog.Layouts.Layout? BreadcrumbLayout { get; set; } public NLog.Layouts.Layout? Dsn { get; set; } + public bool EnableLogs { get; set; } public NLog.Layouts.Layout? Environment { get; set; } public int FlushTimeoutSeconds { get; set; } public bool IgnoreEventsWithNoException { get; set; } diff --git a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 00a36bc53b..9e7456b450 100644 --- a/test/Sentry.NLog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.NLog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -59,6 +59,7 @@ namespace Sentry.NLog public NLog.Layouts.Layout? BreadcrumbCategory { get; set; } public NLog.Layouts.Layout? BreadcrumbLayout { get; set; } public NLog.Layouts.Layout? Dsn { get; set; } + public bool EnableLogs { get; set; } public NLog.Layouts.Layout? Environment { get; set; } public int FlushTimeoutSeconds { get; set; } public bool IgnoreEventsWithNoException { get; set; } diff --git a/test/Sentry.NLog.Tests/Sentry.NLog.Tests.csproj b/test/Sentry.NLog.Tests/Sentry.NLog.Tests.csproj index d7f32032ed..871bc9b0b5 100644 --- a/test/Sentry.NLog.Tests/Sentry.NLog.Tests.csproj +++ b/test/Sentry.NLog.Tests/Sentry.NLog.Tests.csproj @@ -21,4 +21,10 @@ + + + SentryTargetTests.cs + + + diff --git a/test/Sentry.NLog.Tests/SentryTargetTests.Structured.cs b/test/Sentry.NLog.Tests/SentryTargetTests.Structured.cs new file mode 100644 index 0000000000..4be2c75220 --- /dev/null +++ b/test/Sentry.NLog.Tests/SentryTargetTests.Structured.cs @@ -0,0 +1,175 @@ +#nullable enable + +namespace Sentry.NLog.Tests; + +public partial class SentryTargetTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Write_StructuredLogging_IsEnabled(bool isEnabled) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = isEnabled; + + var logger = _fixture.GetLogger(); + + logger.Info("Message"); + + capturer.Logs.Should().HaveCount(isEnabled ? 1 : 0); + } + + public static TheoryData SentryLogLevelData => new() + { + { LogLevel.Trace, SentryLogLevel.Trace }, + { LogLevel.Debug, SentryLogLevel.Debug }, + { LogLevel.Info, SentryLogLevel.Info }, + { LogLevel.Warn, SentryLogLevel.Warning }, + { LogLevel.Error, SentryLogLevel.Error }, + { LogLevel.Fatal, SentryLogLevel.Fatal }, + }; + + [Theory] + [MemberData(nameof(SentryLogLevelData))] + public void Write_StructuredLogging_LogLevel(LogLevel level, SentryLogLevel expected) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var logger = _fixture.GetLogger(); + + logger.Log(level, "Message"); + + capturer.Logs.Should().ContainSingle().Which.Level.Should().Be(expected); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Write_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 logger = _fixture.GetLogger() + .WithProperty("Text-Property-Key", "Text-Property-Value") + .WithProperty("Number-Property", 42) + .WithProperty("Collection-Property", new[] { 41, 42, 43 }) + .WithProperty("Map-Property", new Dictionary { { "key", "value" } }) + .WithProperty("Object-Property", (Number: 42, Text: "42")); + + logger.Info("{}, {Text}, {Number}, {Collection}, {Map}, {Object}.", + null, "Text", 42, new[] { 41, 42, 43 }, new Dictionary { { "key", "value" } }, (Number: 42, Text: "42")); + + 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("""NULL, "Text", 42, 41, 42, 43, "key"="value", (42, 42)."""); + log.Template.Should().Be("{}, {Text}, {Number}, {Collection}, {Map}, {Object}."); + log.Parameters.Should().HaveCount(6); + log.Parameters[0].Should().BeEquivalentTo(new KeyValuePair("", null)); + log.Parameters[1].Should().BeEquivalentTo(new KeyValuePair("Text", "Text")); + log.Parameters[2].Should().BeEquivalentTo(new KeyValuePair("Number", 42)); + log.Parameters[3].Should().BeEquivalentTo(new KeyValuePair("Collection", new[] { 41, 42, 43 })); + log.Parameters[4].Should().BeEquivalentTo(new KeyValuePair("Map", new Dictionary { { "key", "value" } })); + log.Parameters[5].Should().BeEquivalentTo(new KeyValuePair("Object", (Number: 42, Text: "42"))); + 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.nlog"); + log.TryGetAttribute("sentry.sdk.name", out object? sdkName).Should().BeTrue(); + sdkName.Should().Be(Constants.SdkName); + log.TryGetAttribute("sentry.sdk.version", out object? sdkVersion).Should().BeTrue(); + sdkVersion.Should().Be(SentryTarget.NameAndVersion.Version); + + log.TryGetAttribute("property.Text-Property-Key", out object? text).Should().BeTrue(); + text.Should().Be("Text-Property-Value"); + log.TryGetAttribute("property.Number-Property", out object? number).Should().BeTrue(); + number.Should().Be(42); + log.TryGetAttribute("property.Collection-Property", out object? collection).Should().BeTrue(); + collection.Should().BeEquivalentTo(new[] { 41, 42, 43 }); + log.TryGetAttribute("property.Map-Property", out object? map).Should().BeTrue(); + map.Should().BeEquivalentTo(new Dictionary { { "key", "value" } }); + log.TryGetAttribute("property.Object-Property", out object? obj).Should().BeTrue(); + obj.Should().Be((Number: 42, Text: "42")); + + log.TryGetAttribute("property.Text", out object? _).Should().BeFalse(); + log.TryGetAttribute("property.Number", out object? _).Should().BeFalse(); + log.TryGetAttribute("property.Collection", out object? _).Should().BeFalse(); + log.TryGetAttribute("property.Map", out object? _).Should().BeFalse(); + log.TryGetAttribute("property.Object", out object? _).Should().BeFalse(); + } + + [Fact] + public void Write_StructuredLogging_IsPositional() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var logger = _fixture.GetLogger(); + + logger.Info("{0}, {1}, {2}, {3}.", 0, 1, 2, 3); + + capturer.Logs.Should().ContainSingle().Which.Parameters.Should().BeEquivalentTo([ + new KeyValuePair("0", 0), + new KeyValuePair("1", 1), + new KeyValuePair("2", 2), + new KeyValuePair("3", 3), + ]); + } + + [Fact] + public void Write_StructuredLoggingWithException_NoBreadcrumb() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var logger = _fixture.GetLogger(); + + logger.Error(new Exception("expected message"), "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 Write_StructuredLoggingWithoutException_LeavesBreadcrumb() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var logger = _fixture.GetLogger(); + + logger.Error((Exception?)null, "Message"); + + capturer.Logs.Should().ContainSingle().Which.Message.Should().Be("Message"); + _fixture.Scope.Breadcrumbs.Should().ContainSingle().Which.Message.Should().Be("Message"); + _fixture.Hub.Received(1).CaptureEvent(Arg.Any()); + } +} diff --git a/test/Sentry.NLog.Tests/SentryTargetTests.cs b/test/Sentry.NLog.Tests/SentryTargetTests.cs index 30fae65e90..bd9694edd3 100644 --- a/test/Sentry.NLog.Tests/SentryTargetTests.cs +++ b/test/Sentry.NLog.Tests/SentryTargetTests.cs @@ -2,7 +2,7 @@ namespace Sentry.NLog.Tests; -public class SentryTargetTests +public partial class SentryTargetTests { private const string DefaultMessage = "This is a logged message"; @@ -592,6 +592,30 @@ public void MinimumBreadcrumbLevel_SetterReplacesOptions() Assert.Equal(expected, target.MinimumBreadcrumbLevel); } + [Fact] + public void EnableLogs_Default_False() + { + var target = (SentryTarget)_fixture.GetTarget(); + Assert.False(target.EnableLogs); + } + + [Fact] + public void EnableLogs_SetInOptions_ReturnsValue() + { + _fixture.Options.EnableLogs = true; + var target = (SentryTarget)_fixture.GetTarget(); + Assert.True(target.EnableLogs); + } + + [Fact] + public void EnableLogs_SetterReplacesOptions() + { + _fixture.Options.EnableLogs = false; + var target = (SentryTarget)_fixture.GetTarget(); + target.EnableLogs = true; + Assert.True(target.EnableLogs); + } + [Fact] public void SendEventPropertiesAsData_Default_True() {