diff --git a/CHANGELOG.md b/CHANGELOG.md index 76e057ca7a..16e64e0555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Add (experimental) _Structured Logs_ integration for `Serilog` ([#4462](https://github.com/getsentry/sentry-dotnet/pull/4462)) + ### Fixes - Upload linked PDBs to fix non-IL-stripped symbolication for iOS ([#4527](https://github.com/getsentry/sentry-dotnet/pull/4527)) diff --git a/samples/Sentry.Samples.Serilog/Program.cs b/samples/Sentry.Samples.Serilog/Program.cs index 59ed09548d..c1822a3714 100644 --- a/samples/Sentry.Samples.Serilog/Program.cs +++ b/samples/Sentry.Samples.Serilog/Program.cs @@ -25,6 +25,8 @@ private static void Main() // Error and higher is sent as event (default is Error) options.MinimumEventLevel = LogEventLevel.Error; options.AttachStacktrace = true; + // send structured logs to Sentry + options.Experimental.EnableLogs = true; // send PII like the username of the user logged in to the device options.SendDefaultPii = true; // Optional Serilog text formatter used to format LogEvent to string. If TextFormatter is set, FormatProvider is ignored. diff --git a/src/Sentry.Serilog/LogLevelExtensions.cs b/src/Sentry.Serilog/LogLevelExtensions.cs index 03a16ea216..07960179b2 100644 --- a/src/Sentry.Serilog/LogLevelExtensions.cs +++ b/src/Sentry.Serilog/LogLevelExtensions.cs @@ -42,4 +42,18 @@ public static BreadcrumbLevel ToBreadcrumbLevel(this LogEventLevel level) _ => (BreadcrumbLevel)level }; } + + public static SentryLogLevel ToSentryLogLevel(this LogEventLevel level) + { + return level switch + { + LogEventLevel.Verbose => SentryLogLevel.Trace, + LogEventLevel.Debug => SentryLogLevel.Debug, + LogEventLevel.Information => SentryLogLevel.Info, + LogEventLevel.Warning => SentryLogLevel.Warning, + LogEventLevel.Error => SentryLogLevel.Error, + LogEventLevel.Fatal => SentryLogLevel.Fatal, + _ => (SentryLogLevel)level, + }; + } } diff --git a/src/Sentry.Serilog/SentrySink.Structured.cs b/src/Sentry.Serilog/SentrySink.Structured.cs new file mode 100644 index 0000000000..6584afb934 --- /dev/null +++ b/src/Sentry.Serilog/SentrySink.Structured.cs @@ -0,0 +1,126 @@ +using Sentry.Internal.Extensions; +using Serilog.Parsing; + +namespace Sentry.Serilog; + +internal sealed partial class SentrySink +{ + private static void CaptureStructuredLog(IHub hub, SentryOptions options, LogEvent logEvent, string formatted, string? template) + { + GetTraceIdAndSpanId(hub, out var traceId, out var spanId); + GetStructuredLoggingParametersAndAttributes(logEvent, out var parameters, out var attributes); + + SentryLog log = new(logEvent.Timestamp, traceId, logEvent.Level.ToSentryLogLevel(), formatted) + { + Template = template, + Parameters = parameters, + ParentSpanId = spanId, + }; + + log.SetDefaultAttributes(options, Sdk); + + foreach (var attribute in attributes) + { + log.SetAttribute(attribute.Key, attribute.Value); + } + + hub.Logger.CaptureLog(log); + } + + private static void GetTraceIdAndSpanId(IHub hub, out SentryId traceId, out SpanId? spanId) + { + var span = hub.GetSpan(); + if (span is not null) + { + traceId = span.TraceId; + spanId = span.SpanId; + return; + } + + var scope = hub.GetScope(); + if (scope is not null) + { + traceId = scope.PropagationContext.TraceId; + spanId = scope.PropagationContext.SpanId; + return; + } + + traceId = SentryId.Empty; + spanId = null; + } + + private static void GetStructuredLoggingParametersAndAttributes(LogEvent logEvent, out ImmutableArray> parameters, out List> attributes) + { + var propertyNames = new HashSet(); + foreach (var token in logEvent.MessageTemplate.Tokens) + { + if (token is PropertyToken property) + { + propertyNames.Add(property.PropertyName); + } + } + + var @params = ImmutableArray.CreateBuilder>(); + attributes = new List>(); + + foreach (var property in logEvent.Properties) + { + if (propertyNames.Contains(property.Key)) + { + foreach (var parameter in GetLogEventProperties(property)) + { + @params.Add(parameter); + } + } + else + { + foreach (var attribute in GetLogEventProperties(property)) + { + attributes.Add(new KeyValuePair($"property.{attribute.Key}", attribute.Value)); + } + } + } + + parameters = @params.DrainToImmutable(); + return; + + static IEnumerable> GetLogEventProperties(KeyValuePair property) + { + if (property.Value is ScalarValue scalarValue) + { + if (scalarValue.Value is not null) + { + yield return new KeyValuePair(property.Key, scalarValue.Value); + } + } + else if (property.Value is SequenceValue sequenceValue) + { + if (sequenceValue.Elements.Count != 0) + { + yield return new KeyValuePair(property.Key, sequenceValue.ToString()); + } + } + else if (property.Value is DictionaryValue dictionaryValue) + { + if (dictionaryValue.Elements.Count != 0) + { + yield return new KeyValuePair(property.Key, dictionaryValue.ToString()); + } + } + else if (property.Value is StructureValue structureValue) + { + foreach (var prop in structureValue.Properties) + { + if (LogEventProperty.IsValidName(prop.Name)) + { + yield return new KeyValuePair($"{property.Key}.{prop.Name}", prop.Value.ToString()); + } + } + } + else if (!property.Value.IsNull()) + { + yield return new KeyValuePair(property.Key, property.Value); + } + } + } +} diff --git a/src/Sentry.Serilog/SentrySink.cs b/src/Sentry.Serilog/SentrySink.cs index b2a6671c67..369d52a673 100644 --- a/src/Sentry.Serilog/SentrySink.cs +++ b/src/Sentry.Serilog/SentrySink.cs @@ -5,7 +5,7 @@ namespace Sentry.Serilog; /// /// /// -internal sealed class SentrySink : ILogEventSink, IDisposable +internal sealed partial class SentrySink : ILogEventSink, IDisposable { private readonly IDisposable? _sdkDisposable; private readonly SentrySerilogOptions _options; @@ -13,6 +13,12 @@ internal sealed class SentrySink : ILogEventSink, IDisposable internal static readonly SdkVersion NameAndVersion = typeof(SentrySink).Assembly.GetNameAndVersion(); + private static readonly SdkVersion Sdk = new() + { + Name = SdkName, + Version = NameAndVersion.Version, + }; + /// /// Serilog SDK name. /// @@ -50,6 +56,11 @@ internal SentrySink( public void Emit(LogEvent logEvent) { + if (!IsEnabled(logEvent)) + { + return; + } + if (isReentrant.Value) { _options.DiagnosticLogger?.LogError($"Reentrant log event detected. Logging when inside the scope of another log event can cause a StackOverflowException. LogEventInfo.Message: {logEvent.MessageTemplate.Text}"); @@ -67,6 +78,15 @@ public void Emit(LogEvent logEvent) } } + private bool IsEnabled(LogEvent logEvent) + { + var options = _hubAccessor().GetSentryOptions(); + + return logEvent.Level >= _options.MinimumEventLevel + || logEvent.Level >= _options.MinimumBreadcrumbLevel + || options?.Experimental.EnableLogs is true; + } + private void InnerEmit(LogEvent logEvent) { if (logEvent.TryGetSourceContext(out var context)) @@ -77,8 +97,7 @@ private void InnerEmit(LogEvent logEvent) } } - var hub = _hubAccessor(); - if (hub is null || !hub.IsEnabled) + if (_hubAccessor() is not { IsEnabled: true } hub) { return; } @@ -122,30 +141,37 @@ private void InnerEmit(LogEvent logEvent) } } - if (logEvent.Level < _options.MinimumBreadcrumbLevel) + if (logEvent.Level >= _options.MinimumBreadcrumbLevel) { - return; + Dictionary? data = null; + if (exception != null && !string.IsNullOrWhiteSpace(formatted)) + { + // Exception.Message won't be used as Breadcrumb message + // Avoid losing it by adding as data: + data = new Dictionary + { + { "exception_message", exception.Message } + }; + } + + hub.AddBreadcrumb( + _clock, + string.IsNullOrWhiteSpace(formatted) + ? exception?.Message ?? "" + : formatted, + context, + data: data, + level: logEvent.Level.ToBreadcrumbLevel()); } - Dictionary? data = null; - if (exception != null && !string.IsNullOrWhiteSpace(formatted)) + // Read the options from the Hub, rather than the Sink's Serilog-Options, because 'EnableLogs' is declared in the base 'SentryOptions', rather than the derived 'SentrySerilogOptions'. + // In cases where Sentry's Serilog-Sink is added without a DSN (i.e., without initializing the SDK) and the SDK is initialized differently (e.g., through ASP.NET Core), + // then the 'EnableLogs' option of this Sink's Serilog-Options is default, but the Hub's Sentry-Options have the actual user-defined value configured. + var options = hub.GetSentryOptions(); + if (options?.Experimental.EnableLogs is true) { - // Exception.Message won't be used as Breadcrumb message - // Avoid losing it by adding as data: - data = new Dictionary - { - {"exception_message", exception.Message} - }; + CaptureStructuredLog(hub, options, logEvent, formatted, template); } - - hub.AddBreadcrumb( - _clock, - string.IsNullOrWhiteSpace(formatted) - ? exception?.Message ?? "" - : formatted, - context, - data: data, - level: logEvent.Level.ToBreadcrumbLevel()); } private static bool IsSentryContext(string context) => diff --git a/src/Sentry.Serilog/SentrySinkExtensions.cs b/src/Sentry.Serilog/SentrySinkExtensions.cs index 924cec9d84..e300ae1697 100644 --- a/src/Sentry.Serilog/SentrySinkExtensions.cs +++ b/src/Sentry.Serilog/SentrySinkExtensions.cs @@ -13,8 +13,8 @@ public static class SentrySinkExtensions /// /// The logger configuration . /// The Sentry DSN (required). - /// Minimum log level to send an event. /// Minimum log level to record a breadcrumb. + /// Minimum log level to send an event. /// The Serilog format provider. /// The Serilog text formatter. /// Whether to include default Personal Identifiable information. @@ -35,6 +35,7 @@ public static class SentrySinkExtensions /// What mode to use for reporting referenced assemblies in each event sent to sentry. Defaults to /// What modes to use for event automatic de-duplication. /// Default tags to add to all events. + /// Whether to send structured logs. /// /// This sample shows how each item may be set from within a configuration file: /// @@ -50,7 +51,7 @@ public static class SentrySinkExtensions /// "dsn": "https://MY-DSN@sentry.io", /// "minimumBreadcrumbLevel": "Verbose", /// "minimumEventLevel": "Error", - /// "outputTemplate": "{Timestamp:o} [{Level:u3}] ({Application}/{MachineName}/{ThreadId}) {Message}{NewLine}{Exception}"/// + /// "outputTemplate": "{Timestamp:o} [{Level:u3}] ({Application}/{MachineName}/{ThreadId}) {Message}{NewLine}{Exception}", /// "sendDefaultPii": false, /// "isEnvironmentUser": false, /// "serverName": "MyServerName", @@ -71,7 +72,8 @@ public static class SentrySinkExtensions /// "defaultTags": { /// "key-1", "value-1", /// "key-2", "value-2" - /// } + /// }, + /// "experimentalEnableLogs": true /// } /// } /// ] @@ -103,7 +105,8 @@ public static LoggerConfiguration Sentry( SentryLevel? diagnosticLevel = null, ReportAssembliesMode? reportAssembliesMode = null, DeduplicateMode? deduplicateMode = null, - Dictionary? defaultTags = null) + Dictionary? defaultTags = null, + bool? experimentalEnableLogs = null) { return loggerConfiguration.Sentry(o => ConfigureSentrySerilogOptions(o, dsn, @@ -128,7 +131,8 @@ public static LoggerConfiguration Sentry( diagnosticLevel, reportAssembliesMode, deduplicateMode, - defaultTags)); + defaultTags, + experimentalEnableLogs)); } /// @@ -157,7 +161,7 @@ public static LoggerConfiguration Sentry( /// "Args": { /// "minimumEventLevel": "Error", /// "minimumBreadcrumbLevel": "Verbose", - /// "outputTemplate": "{Timestamp:o} [{Level:u3}] ({Application}/{MachineName}/{ThreadId}) {Message}{NewLine}{Exception}"/// + /// "outputTemplate": "{Timestamp:o} [{Level:u3}] ({Application}/{MachineName}/{ThreadId}) {Message}{NewLine}{Exception}" /// } /// } /// ] @@ -205,7 +209,8 @@ internal static void ConfigureSentrySerilogOptions( SentryLevel? diagnosticLevel = null, ReportAssembliesMode? reportAssembliesMode = null, DeduplicateMode? deduplicateMode = null, - Dictionary? defaultTags = null) + Dictionary? defaultTags = null, + bool? experimentalEnableLogs = null) { if (dsn is not null) { @@ -317,6 +322,11 @@ internal static void ConfigureSentrySerilogOptions( sentrySerilogOptions.DeduplicateMode = deduplicateMode.Value; } + if (experimentalEnableLogs.HasValue) + { + sentrySerilogOptions.Experimental.EnableLogs = experimentalEnableLogs.Value; + } + // Serilog-specific items sentrySerilogOptions.InitializeSdk = dsn is not null; // Inferred from the Sentry overload that is used if (defaultTags?.Count > 0) @@ -354,7 +364,6 @@ public static LoggerConfiguration Sentry( sdkDisposable = SentrySdk.Init(options); } - var minimumOverall = (LogEventLevel)Math.Min((int)options.MinimumBreadcrumbLevel, (int)options.MinimumEventLevel); - return loggerConfiguration.Sink(new SentrySink(options, sdkDisposable), minimumOverall); + return loggerConfiguration.Sink(new SentrySink(options, sdkDisposable)); } } diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index b506b9da6c..7e58fec173 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -9,6 +9,7 @@ namespace Sentry; /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] +[DebuggerDisplay(@"SentryLog \{ Level = {Level}, Message = '{Message}' \}")] public sealed class SentryLog { private readonly Dictionary _attributes; diff --git a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 1455bbc51b..f204ed0701 100644 --- a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -46,6 +46,7 @@ namespace Serilog Sentry.SentryLevel? diagnosticLevel = default, Sentry.ReportAssembliesMode? reportAssembliesMode = default, Sentry.DeduplicateMode? deduplicateMode = default, - System.Collections.Generic.Dictionary? defaultTags = null) { } + System.Collections.Generic.Dictionary? defaultTags = null, + bool? experimentalEnableLogs = default) { } } } \ No newline at end of file diff --git a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 1455bbc51b..f204ed0701 100644 --- a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -46,6 +46,7 @@ namespace Serilog Sentry.SentryLevel? diagnosticLevel = default, Sentry.ReportAssembliesMode? reportAssembliesMode = default, Sentry.DeduplicateMode? deduplicateMode = default, - System.Collections.Generic.Dictionary? defaultTags = null) { } + System.Collections.Generic.Dictionary? defaultTags = null, + bool? experimentalEnableLogs = default) { } } } \ No newline at end of file diff --git a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 1455bbc51b..f204ed0701 100644 --- a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -46,6 +46,7 @@ namespace Serilog Sentry.SentryLevel? diagnosticLevel = default, Sentry.ReportAssembliesMode? reportAssembliesMode = default, Sentry.DeduplicateMode? deduplicateMode = default, - System.Collections.Generic.Dictionary? defaultTags = null) { } + System.Collections.Generic.Dictionary? defaultTags = null, + bool? experimentalEnableLogs = default) { } } } \ No newline at end of file diff --git a/test/Sentry.Serilog.Tests/AspNetCoreIntegrationTests.cs b/test/Sentry.Serilog.Tests/AspNetCoreIntegrationTests.cs index 8088548272..760b5b84ff 100644 --- a/test/Sentry.Serilog.Tests/AspNetCoreIntegrationTests.cs +++ b/test/Sentry.Serilog.Tests/AspNetCoreIntegrationTests.cs @@ -1,4 +1,6 @@ #if NET6_0_OR_GREATER +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Sentry.AspNetCore.TestUtils; namespace Sentry.Serilog.Tests; @@ -22,5 +24,52 @@ public async Task UnhandledException_MarkedAsUnhandled() Assert.Contains(Events, e => e.Logger == "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware"); Assert.Collection(Events, @event => Assert.Collection(@event.SentryExceptions, x => Assert.False(x.Mechanism?.Handled))); } + + [Fact] + public async Task StructuredLogging_Disabled() + { + Assert.False(ExperimentalEnableLogs); + + var handler = new RequestHandler + { + Path = "/log", + Handler = context => + { + context.RequestServices.GetRequiredService>().LogInformation("Hello, World!"); + return Task.CompletedTask; + } + }; + + Handlers = new[] { handler }; + Build(); + await HttpClient.GetAsync(handler.Path); + await ServiceProvider.GetRequiredService().FlushAsync(); + + Assert.Empty(Logs); + } + + [Fact] + public async Task StructuredLogging_Enabled() + { + ExperimentalEnableLogs = true; + + var handler = new RequestHandler + { + Path = "/log", + Handler = context => + { + context.RequestServices.GetRequiredService>().LogInformation("Hello, World!"); + return Task.CompletedTask; + } + }; + + Handlers = new[] { handler }; + Build(); + await HttpClient.GetAsync(handler.Path); + await ServiceProvider.GetRequiredService().FlushAsync(); + + Assert.NotEmpty(Logs); + Assert.Contains(Logs, log => log.Level == SentryLogLevel.Info && log.Message == "Hello, World!"); + } } #endif diff --git a/test/Sentry.Serilog.Tests/IntegrationTests.StructuredLogging.verified.txt b/test/Sentry.Serilog.Tests/IntegrationTests.StructuredLogging.verified.txt new file mode 100644 index 0000000000..2eb81f0805 --- /dev/null +++ b/test/Sentry.Serilog.Tests/IntegrationTests.StructuredLogging.verified.txt @@ -0,0 +1,70 @@ +{ + envelopes: [ + { + Header: { + sdk: { + name: sentry.dotnet + } + }, + Items: [ + { + Header: { + content_type: application/vnd.sentry.items.log+json, + item_count: 4, + type: log + }, + Payload: { + Source: { + Length: 4 + } + } + } + ] + } + ], + logs: [ + [ + { + Level: Debug, + Message: Debug message with a Scalar property: 42, + Template: Debug message with a Scalar property: {Scalar}, + Parameters: [ + { + Scalar: 42 + } + ] + }, + { + Level: Info, + Message: Information message with a Sequence property: [41, 42, 43], + Template: Information message with a Sequence property: {Sequence}, + Parameters: [ + { + Sequence: [41, 42, 43] + } + ] + }, + { + Level: Warning, + Message: Warning message with a Dictionary property: [("key": "value")], + Template: Warning message with a Dictionary property: {Dictionary}, + Parameters: [ + { + Dictionary: [("key": "value")] + } + ] + }, + { + Level: Error, + Message: Error message with a Structure property: [42, "42"], + Template: Error message with a Structure property: {Structure}, + Parameters: [ + { + Structure: [42, "42"] + } + ] + } + ] + ], + diagnostics: [] +} \ No newline at end of file diff --git a/test/Sentry.Serilog.Tests/IntegrationTests.verify.cs b/test/Sentry.Serilog.Tests/IntegrationTests.verify.cs index 10d7b538bc..aab8e7dd17 100644 --- a/test/Sentry.Serilog.Tests/IntegrationTests.verify.cs +++ b/test/Sentry.Serilog.Tests/IntegrationTests.verify.cs @@ -100,5 +100,45 @@ public Task LoggingInsideTheContextOfLogging() }) .IgnoreStandardSentryMembers(); } + + [Fact] + public Task StructuredLogging() + { + var transport = new RecordingTransport(); + + var configuration = new LoggerConfiguration(); + configuration.MinimumLevel.Debug(); + var diagnosticLogger = new InMemoryDiagnosticLogger(); + configuration.WriteTo.Sentry( + _ => + { + _.MinimumEventLevel = (LogEventLevel)int.MaxValue; + _.Experimental.EnableLogs = true; + _.Transport = transport; + _.DiagnosticLogger = diagnosticLogger; + _.Dsn = ValidDsn; + _.Debug = true; + _.Environment = "test-environment"; + _.Release = "test-release"; + }); + + Log.Logger = configuration.CreateLogger(); + + Log.Debug("Debug message with a Scalar property: {Scalar}", 42); + Log.Information("Information message with a Sequence property: {Sequence}", new object[] { new int[] { 41, 42, 43 } }); + Log.Warning("Warning message with a Dictionary property: {Dictionary}", new Dictionary { { "key", "value" } }); + Log.Error("Error message with a Structure property: {Structure}", (Number: 42, Text: "42")); + + Log.CloseAndFlush(); + + var envelopes = transport.Envelopes; + var logs = transport.Payloads.OfType() + .Select(payload => payload.Source) + .OfType() + .Select(log => log.Items.ToArray()); + var diagnostics = diagnosticLogger.Entries.Where(_ => _.Level >= SentryLevel.Warning); + return Verify(new { envelopes, logs, diagnostics }) + .IgnoreStandardSentryMembers(); + } } #endif diff --git a/test/Sentry.Serilog.Tests/SentrySerilogSinkExtensionsTests.cs b/test/Sentry.Serilog.Tests/SentrySerilogSinkExtensionsTests.cs index 57f5fcf9a5..c0cda5c45a 100644 --- a/test/Sentry.Serilog.Tests/SentrySerilogSinkExtensionsTests.cs +++ b/test/Sentry.Serilog.Tests/SentrySerilogSinkExtensionsTests.cs @@ -28,6 +28,7 @@ private class Fixture public bool InitializeSdk { get; } = false; public LogEventLevel MinimumEventLevel { get; } = LogEventLevel.Verbose; public LogEventLevel MinimumBreadcrumbLevel { get; } = LogEventLevel.Fatal; + public bool ExperimentalEnableLogs { get; } = true; public static SentrySerilogOptions GetSut() => new(); } @@ -97,7 +98,7 @@ public void ConfigureSentrySerilogOptions_WithAllParameters_MakesAppropriateChan _fixture.SampleRate, _fixture.Release, _fixture.Environment, _fixture.MaxQueueItems, _fixture.ShutdownTimeout, _fixture.DecompressionMethods, _fixture.RequestBodyCompressionLevel, _fixture.RequestBodyCompressionBuffered, _fixture.Debug, _fixture.DiagnosticLevel, - _fixture.ReportAssembliesMode, _fixture.DeduplicateMode); + _fixture.ReportAssembliesMode, _fixture.DeduplicateMode, null, _fixture.ExperimentalEnableLogs); // Compare individual properties Assert.Equal(_fixture.SendDefaultPii, sut.SendDefaultPii); @@ -108,7 +109,7 @@ public void ConfigureSentrySerilogOptions_WithAllParameters_MakesAppropriateChan Assert.Equal(_fixture.SampleRate, sut.SampleRate); Assert.Equal(_fixture.Release, sut.Release); Assert.Equal(_fixture.Environment, sut.Environment); - Assert.Equal(_fixture.Dsn, sut.Dsn!); + Assert.Equal(_fixture.Dsn, sut.Dsn); Assert.Equal(_fixture.MaxQueueItems, sut.MaxQueueItems); Assert.Equal(_fixture.ShutdownTimeout, sut.ShutdownTimeout); Assert.Equal(_fixture.DecompressionMethods, sut.DecompressionMethods); @@ -118,6 +119,7 @@ public void ConfigureSentrySerilogOptions_WithAllParameters_MakesAppropriateChan Assert.Equal(_fixture.DiagnosticLevel, sut.DiagnosticLevel); Assert.Equal(_fixture.ReportAssembliesMode, sut.ReportAssembliesMode); Assert.Equal(_fixture.DeduplicateMode, sut.DeduplicateMode); + Assert.Equal(_fixture.ExperimentalEnableLogs, sut.Experimental.EnableLogs); Assert.True(sut.InitializeSdk); Assert.Equal(_fixture.MinimumEventLevel, sut.MinimumEventLevel); Assert.Equal(_fixture.MinimumBreadcrumbLevel, sut.MinimumBreadcrumbLevel); diff --git a/test/Sentry.Serilog.Tests/SentrySinkTests.Structured.cs b/test/Sentry.Serilog.Tests/SentrySinkTests.Structured.cs new file mode 100644 index 0000000000..b7cb36b76f --- /dev/null +++ b/test/Sentry.Serilog.Tests/SentrySinkTests.Structured.cs @@ -0,0 +1,135 @@ +#nullable enable + +namespace Sentry.Serilog.Tests; + +public partial class SentrySinkTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Emit_StructuredLogging_IsEnabled(bool isEnabled) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.Experimental.EnableLogs = isEnabled; + + var sut = _fixture.GetSut(); + var logger = new LoggerConfiguration().WriteTo.Sink(sut).MinimumLevel.Verbose().CreateLogger(); + + logger.Write(LogEventLevel.Information, "Message"); + + capturer.Logs.Should().HaveCount(isEnabled ? 1 : 0); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Emit_StructuredLogging_UseHubOptionsOverSinkOptions(bool isEnabled) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.Experimental.EnableLogs = true; + + if (!isEnabled) + { + SentryClientExtensions.SentryOptionsForTestingOnly = null; + } + + var sut = _fixture.GetSut(); + var logger = new LoggerConfiguration().WriteTo.Sink(sut).MinimumLevel.Verbose().CreateLogger(); + + logger.Write(LogEventLevel.Information, "Message"); + + capturer.Logs.Should().HaveCount(isEnabled ? 1 : 0); + } + + [Theory] + [InlineData(LogEventLevel.Verbose, SentryLogLevel.Trace)] + [InlineData(LogEventLevel.Debug, SentryLogLevel.Debug)] + [InlineData(LogEventLevel.Information, SentryLogLevel.Info)] + [InlineData(LogEventLevel.Warning, SentryLogLevel.Warning)] + [InlineData(LogEventLevel.Error, SentryLogLevel.Error)] + [InlineData(LogEventLevel.Fatal, SentryLogLevel.Fatal)] + public void Emit_StructuredLogging_LogLevel(LogEventLevel level, SentryLogLevel expected) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.Experimental.EnableLogs = true; + + var sut = _fixture.GetSut(); + var logger = new LoggerConfiguration().WriteTo.Sink(sut).MinimumLevel.Verbose().CreateLogger(); + + logger.Write(level, "Message"); + + capturer.Logs.Should().ContainSingle().Which.Level.Should().Be(expected); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Emit_StructuredLogging_LogEvent(bool withActiveSpan) + { + InMemorySentryStructuredLogger capturer = new(); + _fixture.Hub.Logger.Returns(capturer); + _fixture.Options.Experimental.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(); + var logger = new LoggerConfiguration() + .WriteTo.Sink(sut) + .MinimumLevel.Verbose() + .Enrich.WithProperty("Scalar-Property", 42) + .Enrich.WithProperty("Sequence-Property", new[] { 41, 42, 43 }) + .Enrich.WithProperty("Dictionary-Property", new Dictionary { { "key", "value" } }) + .Enrich.WithProperty("Structure-Property", (Number: 42, Text: "42")) + .CreateLogger(); + + logger.Write(LogEventLevel.Information, + "Message with Scalar property {Scalar}, Sequence property: {Sequence}, Dictionary property: {Dictionary}, and Structure property: {Structure}.", + 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("""Message with Scalar property 42, Sequence property: [41, 42, 43], Dictionary property: [("key": "value")], and Structure property: [42, "42"]."""); + log.Template.Should().Be("Message with Scalar property {Scalar}, Sequence property: {Sequence}, Dictionary property: {Dictionary}, and Structure property: {Structure}."); + log.Parameters.Should().HaveCount(4); + log.Parameters[0].Should().BeEquivalentTo(new KeyValuePair("Scalar", 42)); + log.Parameters[1].Should().BeEquivalentTo(new KeyValuePair("Sequence", "[41, 42, 43]")); + log.Parameters[2].Should().BeEquivalentTo(new KeyValuePair("Dictionary", """[("key": "value")]""")); + log.Parameters[3].Should().BeEquivalentTo(new KeyValuePair("Structure", """[42, "42"]""")); + log.ParentSpanId.Should().Be(withActiveSpan ? _fixture.Hub.GetSpan()!.SpanId : _fixture.Scope.PropagationContext.SpanId); + + 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.sdk.name", out object? sdkName).Should().BeTrue(); + sdkName.Should().Be(SentrySink.SdkName); + log.TryGetAttribute("sentry.sdk.version", out object? sdkVersion).Should().BeTrue(); + sdkVersion.Should().Be(SentrySink.NameAndVersion.Version); + + log.TryGetAttribute("property.Scalar-Property", out object? scalar).Should().BeTrue(); + scalar.Should().Be(42); + log.TryGetAttribute("property.Sequence-Property", out object? sequence).Should().BeTrue(); + sequence.Should().Be("[41, 42, 43]"); + log.TryGetAttribute("property.Dictionary-Property", out object? dictionary).Should().BeTrue(); + dictionary.Should().Be("""[("key": "value")]"""); + log.TryGetAttribute("property.Structure-Property", out object? structure).Should().BeTrue(); + structure.Should().Be("""[42, "42"]"""); + } +} diff --git a/test/Sentry.Serilog.Tests/SentrySinkTests.cs b/test/Sentry.Serilog.Tests/SentrySinkTests.cs index ce1011aa2b..0ed6e94139 100644 --- a/test/Sentry.Serilog.Tests/SentrySinkTests.cs +++ b/test/Sentry.Serilog.Tests/SentrySinkTests.cs @@ -1,6 +1,6 @@ namespace Sentry.Serilog.Tests; -public class SentrySinkTests +public partial class SentrySinkTests { private class Fixture { @@ -15,6 +15,7 @@ public Fixture() Hub.IsEnabled.Returns(true); HubAccessor = () => Hub; Hub.SubstituteConfigureScope(Scope); + SentryClientExtensions.SentryOptionsForTestingOnly = Options; } public SentrySink GetSut() diff --git a/test/Sentry.Serilog.Tests/SerilogAspNetSentrySdkTestFixture.cs b/test/Sentry.Serilog.Tests/SerilogAspNetSentrySdkTestFixture.cs index 4510240ceb..b7b1a6d764 100644 --- a/test/Sentry.Serilog.Tests/SerilogAspNetSentrySdkTestFixture.cs +++ b/test/Sentry.Serilog.Tests/SerilogAspNetSentrySdkTestFixture.cs @@ -6,13 +6,21 @@ namespace Sentry.Serilog.Tests; public class SerilogAspNetSentrySdkTestFixture : AspNetSentrySdkTestFixture { protected List Events; + protected List Logs; + + protected bool ExperimentalEnableLogs { get; set; } protected override void ConfigureBuilder(WebHostBuilder builder) { Events = new List(); + Logs = new List(); + Configure = options => { options.SetBeforeSend((@event, _) => { Events.Add(@event); return @event; }); + + options.Experimental.EnableLogs = ExperimentalEnableLogs; + options.Experimental.SetBeforeSendLog(log => { Logs.Add(log); return log; }); }; ConfigureApp = app => @@ -27,7 +35,7 @@ protected override void ConfigureBuilder(WebHostBuilder builder) builder.ConfigureLogging(loggingBuilder => { var logger = new LoggerConfiguration() - .WriteTo.Sentry(ValidDsn) + .WriteTo.Sentry(ValidDsn, experimentalEnableLogs: ExperimentalEnableLogs) .CreateLogger(); loggingBuilder.AddSerilog(logger); }); diff --git a/test/Sentry.Testing/InMemorySentryStructuredLogger.cs b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs index 440b83cdc7..0dfde97564 100644 --- a/test/Sentry.Testing/InMemorySentryStructuredLogger.cs +++ b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs @@ -5,6 +5,7 @@ namespace Sentry.Testing; public sealed class InMemorySentryStructuredLogger : SentryStructuredLogger { public List Entries { get; } = new(); + public List Logs { get; } = new(); /// private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) @@ -15,7 +16,7 @@ private protected override void CaptureLog(SentryLogLevel level, string template /// protected internal override void CaptureLog(SentryLog log) { - throw new NotSupportedException(); + Logs.Add(log); } /// diff --git a/test/Sentry.Testing/RecordingTransport.cs b/test/Sentry.Testing/RecordingTransport.cs index 386be50b9b..ba0566a88c 100644 --- a/test/Sentry.Testing/RecordingTransport.cs +++ b/test/Sentry.Testing/RecordingTransport.cs @@ -1,5 +1,7 @@ using ISerializable = Sentry.Protocol.Envelopes.ISerializable; +namespace Sentry.Testing; + public class RecordingTransport : ITransport { private List _envelopes = new(); diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 07c413e828..444cbfe027 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -613,6 +613,7 @@ namespace Sentry Fatal = 4, } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Diagnostics.DebuggerDisplay("SentryLog \\{ Level = {Level}, Message = \'{Message}\' \\}")] [System.Runtime.CompilerServices.RequiredMember] public sealed class SentryLog { diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 07c413e828..444cbfe027 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -613,6 +613,7 @@ namespace Sentry Fatal = 4, } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Diagnostics.DebuggerDisplay("SentryLog \\{ Level = {Level}, Message = \'{Message}\' \\}")] [System.Runtime.CompilerServices.RequiredMember] public sealed class SentryLog { diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 2de7c68513..cd961b2d1d 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -599,6 +599,7 @@ namespace Sentry [System.Runtime.Serialization.EnumMember(Value="fatal")] Fatal = 4, } + [System.Diagnostics.DebuggerDisplay("SentryLog \\{ Level = {Level}, Message = \'{Message}\' \\}")] public sealed class SentryLog { public Sentry.SentryLogLevel Level { get; init; }