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..66de2c3b4d --- /dev/null +++ b/src/Sentry.NLog/SentryTarget.Structured.cs @@ -0,0 +1,91 @@ +namespace Sentry.NLog; + +public sealed partial class SentryTarget +{ + private static void CaptureStructuredLog(IHub hub, SentryOptions options, LogEventInfo logEvent) + { + if (logEvent.Level.ToSentryLogLevel() is not { } level) + { + return; + } + + DateTimeOffset timestamp = new(logEvent.TimeStamp); + GetStructuredLoggingParametersAndAttributes(logEvent, out var parameters, out var attributes); + + var log = SentryLog.Create(hub, timestamp, level, logEvent.FormattedMessage, logEvent.Message, parameters); + + var scope = hub.GetScope(); + log.SetDefaultAttributes(options, scope, Sdk); + log.SetOrigin("auto.log.nlog"); + + if (logEvent.LoggerName is not null) + { + log.Attributes.SetAttribute("category.name", logEvent.LoggerName); + } + + 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 = []; + + if (!logEvent.HasProperties) + { + return; + } + + 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; + } + + // The HashSet capacity constructor is unavailable on netstandard2.0 and net462 (added in net472). +#if NETSTANDARD2_0 || NET462 + parameterNames = new HashSet(); +#else + parameterNames = new HashSet(parameters.Count); +#endif + + var @params = ImmutableArray.CreateBuilder>(parameters.Count); + + var index = 0; + foreach (var parameter in parameters) + { + // NLog allows passing unnamed holes (e.g. `{}`) - parameters with no names. + // To prevent a collision on the attribute key when multiple unnamed holes are present, we fall back to the + // positional index of the parameter in these cases (matching the behaviour of MEL). + var name = string.IsNullOrEmpty(parameter.Name) + ? index.ToString(CultureInfo.InvariantCulture) + : parameter.Name; + + parameterNames.Add(name); + @params.Add(new KeyValuePair(name, parameter.Value)); + index++; + } + + return @params.DrainToImmutable(); + } +} diff --git a/src/Sentry.NLog/SentryTarget.cs b/src/Sentry.NLog/SentryTarget.cs index b799e525ab..e1777e49ff 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. /// @@ -321,129 +336,155 @@ private void InnerWrite(LogEventInfo logEvent) } var hub = HubAccessor(); - if (!hub.IsEnabled) { return; } var exception = logEvent.Exception; - var shouldOnlyLogExceptions = exception == null && IgnoreEventsWithNoException; + var shouldIgnoreEvent = exception == null && IgnoreEventsWithNoException; var shouldIncludeProperties = ContextProperties?.Count > 0 || ShouldIncludeProperties(logEvent); + var addedBreadcrumbForException = false; - if (logEvent.Level >= Options.MinimumEventLevel && !shouldOnlyLogExceptions) + if (logEvent.Level >= Options.MinimumEventLevel && !shouldIgnoreEvent) { - var formatted = RenderLogEvent(Layout, logEvent); - var template = logEvent.Message; + CreateSentryEvent(logEvent, exception, shouldIncludeProperties, hub); - var evt = new SentryEvent(exception) + // Capturing exception events adds a breadcrumb automatically... we don't want to add another one + if (exception != null) { - Message = new SentryMessage - { - Formatted = formatted, - Message = template - }, - Logger = logEvent.LoggerName, - Level = logEvent.Level.ToSentryLevel(), - Release = Options.Release, - Environment = Options.Environment, - User = GetUser(logEvent) ?? new SentryUser(), - }; + addedBreadcrumbForException = true; + } + } - if (evt.Sdk is { } sdk) - { - sdk.Name = Constants.SdkName; - sdk.Version = NameAndVersion.Version; + // Whether or not it was sent as an event, add breadcrumb so the next event includes it + if (!addedBreadcrumbForException && logEvent.Level >= Options.MinimumBreadcrumbLevel) + { + CreateBreadcrumb(logEvent, exception, shouldIncludeProperties, hub); + } - if (NameAndVersion.Version is { } version) - { - sdk.AddPackage(ProtocolPackageName, version); - } + // Read the options from the Hub rather than the Target's NLog-Options because 'EnableLogs' is declared in the + // base 'SentryOptions', rather than the derived 'SentryNLogOptions'. If the NLog-Target is added without a DSN + // (i.e. without initialising the SDK), then base options will only be initialised in the Hub options. + var sentryOptions = hub.GetSentryOptions(); + if (sentryOptions?.EnableLogs is true) + { + try + { + CaptureStructuredLog(hub, sentryOptions, logEvent); } - - if (Tags.Count > 0 || IncludeEventPropertiesAsTags && logEvent.HasProperties) + catch (Exception ex) { - evt.SetTags(GetTagsFromLogEvent(logEvent)); + sentryOptions.DiagnosticLogger?.LogError(ex, "Failed to capture structured log. The log will be dropped."); } + } + } + + private void CreateBreadcrumb(LogEventInfo logEvent, Exception? exception, bool shouldIncludeProperties, IHub hub) + { + var breadcrumbFormatted = RenderLogEvent(BreadcrumbLayout, logEvent); + var breadcrumbCategory = RenderLogEvent(BreadcrumbCategory, logEvent); + if (string.IsNullOrEmpty(breadcrumbCategory)) + { + breadcrumbCategory = null; + } + + var message = string.IsNullOrWhiteSpace(breadcrumbFormatted) + ? exception?.Message ?? logEvent.FormattedMessage + : breadcrumbFormatted; + + IDictionary? data = null; + // If this is true, an exception is being logged with no custom message + if (exception?.Message != null && !message.StartsWith(exception.Message)) + { + // Exception won't be used as Breadcrumb message. Avoid losing it by adding as data: + data = new Dictionary + { + {"exception_type", exception.GetType().ToString()}, + {"exception_message", exception.Message}, + }; + } + if (IncludeEventDataOnBreadcrumbs) + { if (shouldIncludeProperties) { var contextProps = GetAllProperties(logEvent); - evt.SetExtras(contextProps); - - if (contextProps.TryGetValue(AdditionalGroupingKeyProperty, out var additionalGroupingKey) - && additionalGroupingKey is string groupingKey) + data ??= new Dictionary(contextProps.Count); + foreach (var contextProp in contextProps) { - var overridenFingerprint = evt.Fingerprint.ToList(); - if (!evt.Fingerprint.Any()) + if (contextProp.Value?.ToString() is { } value) { - overridenFingerprint.Add("{{ default }}"); + data.Add(contextProp.Key, value); } - - overridenFingerprint.Add(groupingKey); - - evt.SetFingerprint(overridenFingerprint); } } + } - _ = hub.CaptureEvent(evt); + hub.AddBreadcrumb( + _clock, + message, + breadcrumbCategory, + data: data, + level: logEvent.Level.ToBreadcrumbLevel()); + } - // Capturing exception events adds a breadcrumb automatically... we don't want to add another one - if (exception != null) - { - return; - } - } + private void CreateSentryEvent(LogEventInfo logEvent, Exception? exception, bool shouldIncludeProperties, IHub hub) + { + var formatted = RenderLogEvent(Layout, logEvent); + var template = logEvent.Message; - // Whether or not it was sent as event, add breadcrumb so the next event includes it - if (logEvent.Level >= Options.MinimumBreadcrumbLevel) + var evt = new SentryEvent(exception) { - var breadcrumbFormatted = RenderLogEvent(BreadcrumbLayout, logEvent); - var breadcrumbCategory = RenderLogEvent(BreadcrumbCategory, logEvent); - if (string.IsNullOrEmpty(breadcrumbCategory)) + Message = new SentryMessage { - breadcrumbCategory = null; - } + Formatted = formatted, + Message = template + }, + Logger = logEvent.LoggerName, + Level = logEvent.Level.ToSentryLevel(), + Release = Options.Release, + Environment = Options.Environment, + User = GetUser(logEvent) ?? new SentryUser(), + }; - var message = string.IsNullOrWhiteSpace(breadcrumbFormatted) - ? exception?.Message ?? logEvent.FormattedMessage - : breadcrumbFormatted; + if (evt.Sdk is { } sdk) + { + sdk.Name = Constants.SdkName; + sdk.Version = NameAndVersion.Version; - IDictionary? data = null; - // If this is true, an exception is being logged with no custom message - if (exception?.Message != null && !message.StartsWith(exception.Message)) + if (NameAndVersion.Version is { } version) { - // Exception won't be used as Breadcrumb message. Avoid losing it by adding as data: - data = new Dictionary - { - {"exception_type", exception.GetType().ToString()}, - {"exception_message", exception.Message}, - }; + sdk.AddPackage(ProtocolPackageName, version); } + } - if (IncludeEventDataOnBreadcrumbs) + if (Tags.Count > 0 || IncludeEventPropertiesAsTags && logEvent.HasProperties) + { + evt.SetTags(GetTagsFromLogEvent(logEvent)); + } + + if (shouldIncludeProperties) + { + var contextProps = GetAllProperties(logEvent); + evt.SetExtras(contextProps); + + if (contextProps.TryGetValue(AdditionalGroupingKeyProperty, out var additionalGroupingKey) + && additionalGroupingKey is string groupingKey) { - if (shouldIncludeProperties) + var overridenFingerprint = evt.Fingerprint.ToList(); + if (!evt.Fingerprint.Any()) { - var contextProps = GetAllProperties(logEvent); - data ??= new Dictionary(contextProps.Count); - foreach (var contextProp in contextProps) - { - if (contextProp.Value?.ToString() is { } value) - { - data.Add(contextProp.Key, value); - } - } + overridenFingerprint.Add("{{ default }}"); } - } - hub.AddBreadcrumb( - _clock, - message, - breadcrumbCategory, - data: data, - level: logEvent.Level.ToBreadcrumbLevel()); + overridenFingerprint.Add(groupingKey); + + evt.SetFingerprint(overridenFingerprint); + } } + + _ = hub.CaptureEvent(evt); } private SentryUser? GetUser(LogEventInfo logEvent) diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index 8e74be21e8..7adce18c53 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 { [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. /// 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..8ef9c778bc --- /dev/null +++ b/test/Sentry.NLog.Tests/SentryTargetTests.Structured.cs @@ -0,0 +1,210 @@ +#nullable enable + +namespace Sentry.NLog.Tests; + +public partial class SentryTargetTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Write_StructuredLogging_UseHubOptionsOverTargetOptions(bool isEnabled) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + if (!isEnabled) + { + SentryClientExtensions.SentryOptionsForTestingOnly = null; + } + + 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("0", 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.Attributes.ShouldContain("sentry.environment", "test-environment"); + log.Attributes.ShouldContain("sentry.release", "test-release"); + log.Attributes.ShouldContain("sentry.origin", "auto.log.nlog"); + log.Attributes.ShouldContain("sentry.sdk.name", Constants.SdkName); + log.Attributes.ShouldContain("sentry.sdk.version", SentryTarget.NameAndVersion.Version); + log.Attributes.ShouldContain("category.name", "sentry"); + + log.Attributes.ShouldContain("property.Text-Property-Key", "Text-Property-Value"); + log.Attributes.ShouldContain("property.Number-Property", 42); + log.Attributes.TryGetAttribute("property.Collection-Property", out int[]? collection).Should().BeTrue(); + collection.Should().BeEquivalentTo(new[] { 41, 42, 43 }); + log.Attributes.TryGetAttribute("property.Map-Property", out Dictionary? map).Should().BeTrue(); + map.Should().BeEquivalentTo(new Dictionary { { "key", "value" } }); + log.Attributes.ShouldContain("property.Object-Property", (Number: 42, Text: "42")); + + log.Attributes.ShouldNotContain("property.Text"); + log.Attributes.ShouldNotContain("property.Number"); + log.Attributes.ShouldNotContain("property.Collection"); + log.Attributes.ShouldNotContain("property.Map"); + log.Attributes.ShouldNotContain("property.Object"); + } + + [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_StructuredLogging_UnnamedHolesDoNotCollide() + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.EnableLogs = true; + + var logger = _fixture.GetLogger(); + + logger.Info("{}, {}, {}.", "first", "second", "third"); + + // Unnamed holes must each keep their value: no empty parameter names (which would serialize to the + // same `sentry.message.parameter.` attribute key) and no collisions. + var parameters = capturer.Logs.Should().ContainSingle().Which.Parameters; + parameters.Select(parameter => parameter.Value).Should().Equal("first", "second", "third"); + parameters.Should().OnlyContain(parameter => !string.IsNullOrEmpty(parameter.Key)); + parameters.Select(parameter => parameter.Key).Should().OnlyHaveUniqueItems(); + } + + [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()); + } + + [Fact] + public void Write_StructuredLoggingThrows_DoesNotBlockEventOrPropagate() + { + // Force the structured-log capture to fail. + _fixture.Hub.Logger.Returns(_ => throw new InvalidOperationException("structured logging failed")); + _fixture.Options.EnableLogs = true; + + var logger = _fixture.GetLogger(); + // Surface any exception that escapes the target, so an unguarded failure would fail this test. + logger.Factory.ThrowExceptions = true; + + // Must not throw, even though the structured-log capture fails. + logger.Error(new Exception("expected message"), "Message"); + + // The higher-priority event must still be captured. + _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..2bb97d6e7a 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"; @@ -24,6 +24,7 @@ public Fixture() HubAccessor = () => Hub; Scope = new Scope(new SentryOptions()); Hub.SubstituteConfigureScope(Scope); + SentryClientExtensions.SentryOptionsForTestingOnly = Options; } public Target GetTarget(bool asyncTarget = false) @@ -592,6 +593,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() { diff --git a/test/Sentry.Testing/Sentry.Testing.csproj b/test/Sentry.Testing/Sentry.Testing.csproj index a1576ba0a5..b8bed45e3c 100644 --- a/test/Sentry.Testing/Sentry.Testing.csproj +++ b/test/Sentry.Testing/Sentry.Testing.csproj @@ -17,6 +17,7 @@ +