diff --git a/CHANGELOG.md b/CHANGELOG.md index 11f2237078..2f57a6c237 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +### Features + +- Add _experimental_ support for [Sentry Structured Logging](https://docs.sentry.io/product/explore/logs/) ([#4308](https://github.com/getsentry/sentry-dotnet/pull/4308)) + - Structured-Logger API ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) + - Buffering and Batching ([#4310](https://github.com/getsentry/sentry-dotnet/pull/4310)) + - Integrations for `Sentry.Extensions.Logging`, `Sentry.AspNetCore` and `Sentry.Maui` ([#4193](https://github.com/getsentry/sentry-dotnet/pull/4193)) + ### Fixes - Update `sample_rate` of _Dynamic Sampling Context (DSC)_ when making sampling decisions ([#4374](https://github.com/getsentry/sentry-dotnet/pull/4374)) diff --git a/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.Extensions.Logging.SentryStructuredLoggerBenchmarks-report-github.md b/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.Extensions.Logging.SentryStructuredLoggerBenchmarks-report-github.md new file mode 100644 index 0000000000..172b688e55 --- /dev/null +++ b/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.Extensions.Logging.SentryStructuredLoggerBenchmarks-report-github.md @@ -0,0 +1,13 @@ +``` + +BenchmarkDotNet v0.13.12, macOS 15.5 (24F74) [Darwin 24.5.0] +Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores +.NET SDK 9.0.301 + [Host] : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD + DefaultJob : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD + + +``` +| Method | Mean | Error | StdDev | Gen0 | Allocated | +|------- |---------:|--------:|--------:|-------:|----------:| +| Log | 288.4 ns | 1.28 ns | 1.20 ns | 0.1163 | 976 B | diff --git a/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.StructuredLogBatchProcessorBenchmarks-report-github.md b/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.StructuredLogBatchProcessorBenchmarks-report-github.md new file mode 100644 index 0000000000..befa791365 --- /dev/null +++ b/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.StructuredLogBatchProcessorBenchmarks-report-github.md @@ -0,0 +1,18 @@ +``` + +BenchmarkDotNet v0.13.12, macOS 15.5 (24F74) [Darwin 24.5.0] +Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores +.NET SDK 9.0.301 + [Host] : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD + DefaultJob : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD + + +``` +| Method | BatchCount | OperationsPerInvoke | Mean | Error | StdDev | Gen0 | Allocated | +|---------------- |----------- |-------------------- |------------:|---------:|---------:|-------:|----------:| +| **EnqueueAndFlush** | **10** | **100** | **1,774.5 ns** | **7.57 ns** | **6.71 ns** | **0.6104** | **5 KB** | +| **EnqueueAndFlush** | **10** | **200** | **3,468.5 ns** | **11.16 ns** | **10.44 ns** | **1.2207** | **10 KB** | +| **EnqueueAndFlush** | **10** | **1000** | **17,259.7 ns** | **51.92 ns** | **46.02 ns** | **6.1035** | **50 KB** | +| **EnqueueAndFlush** | **100** | **100** | **857.5 ns** | **4.21 ns** | **3.73 ns** | **0.1469** | **1.2 KB** | +| **EnqueueAndFlush** | **100** | **200** | **1,681.4 ns** | **1.74 ns** | **1.63 ns** | **0.2937** | **2.41 KB** | +| **EnqueueAndFlush** | **100** | **1000** | **8,302.2 ns** | **12.00 ns** | **10.64 ns** | **1.4648** | **12.03 KB** | diff --git a/benchmarks/Sentry.Benchmarks/Extensions.Logging/SentryStructuredLoggerBenchmarks.cs b/benchmarks/Sentry.Benchmarks/Extensions.Logging/SentryStructuredLoggerBenchmarks.cs new file mode 100644 index 0000000000..3e18747a65 --- /dev/null +++ b/benchmarks/Sentry.Benchmarks/Extensions.Logging/SentryStructuredLoggerBenchmarks.cs @@ -0,0 +1,90 @@ +#nullable enable + +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.Logging; +using Sentry.Extensibility; +using Sentry.Extensions.Logging; +using Sentry.Internal; +using Sentry.Testing; + +namespace Sentry.Benchmarks.Extensions.Logging; + +public class SentryStructuredLoggerBenchmarks +{ + private Hub _hub = null!; + private Sentry.Extensions.Logging.SentryStructuredLogger _logger = null!; + private LogRecord _logRecord = null!; + private SentryLog? _lastLog; + + [GlobalSetup] + public void Setup() + { + SentryLoggingOptions options = new() + { + Dsn = DsnSamples.ValidDsn, + Experimental = + { + EnableLogs = true, + }, + ExperimentalLogging = + { + MinimumLogLevel = LogLevel.Information, + } + }; + options.Experimental.SetBeforeSendLog((SentryLog log) => + { + _lastLog = log; + return null; + }); + + MockClock clock = new(new DateTimeOffset(2025, 04, 22, 14, 51, 00, 789, TimeSpan.FromHours(2))); + SdkVersion sdk = new() + { + Name = "SDK Name", + Version = "SDK Version", + }; + + _hub = new Hub(options, DisabledHub.Instance); + _logger = new Sentry.Extensions.Logging.SentryStructuredLogger("CategoryName", options, _hub, clock, sdk); + _logRecord = new LogRecord(LogLevel.Information, new EventId(2025, "EventName"), new InvalidOperationException("exception-message"), "Number={Number}, Text={Text}", 2018, "message"); + } + + [Benchmark] + public void Log() + { + _logger.Log(_logRecord.LogLevel, _logRecord.EventId, _logRecord.Exception, _logRecord.Message, _logRecord.Args); + } + + [GlobalCleanup] + public void Cleanup() + { + _hub.Dispose(); + + if (_lastLog is null) + { + throw new InvalidOperationException("Last Log is null"); + } + if (_lastLog.Message != "Number=2018, Text=message") + { + throw new InvalidOperationException($"Last Log with Message: '{_lastLog.Message}'"); + } + } + + private sealed class LogRecord + { + public LogRecord(LogLevel logLevel, EventId eventId, Exception? exception, string? message, params object?[] args) + { + LogLevel = logLevel; + EventId = eventId; + Exception = exception; + Message = message; + Args = args; + } + + public LogLevel LogLevel { get; } + public EventId EventId { get; } + public Exception? Exception { get; } + public string? Message { get; } + public object?[] Args { get; } + } +} diff --git a/benchmarks/Sentry.Benchmarks/Sentry.Benchmarks.csproj b/benchmarks/Sentry.Benchmarks/Sentry.Benchmarks.csproj index bdb8ed918a..48231469f2 100644 --- a/benchmarks/Sentry.Benchmarks/Sentry.Benchmarks.csproj +++ b/benchmarks/Sentry.Benchmarks/Sentry.Benchmarks.csproj @@ -14,6 +14,7 @@ + diff --git a/benchmarks/Sentry.Benchmarks/StructuredLogBatchProcessorBenchmarks.cs b/benchmarks/Sentry.Benchmarks/StructuredLogBatchProcessorBenchmarks.cs new file mode 100644 index 0000000000..336d726926 --- /dev/null +++ b/benchmarks/Sentry.Benchmarks/StructuredLogBatchProcessorBenchmarks.cs @@ -0,0 +1,68 @@ +using BenchmarkDotNet.Attributes; +using NSubstitute; +using Sentry.Extensibility; +using Sentry.Internal; + +namespace Sentry.Benchmarks; + +public class StructuredLogBatchProcessorBenchmarks +{ + private Hub _hub; + private StructuredLogBatchProcessor _batchProcessor; + private SentryLog _log; + + [Params(10, 100)] + public int BatchCount { get; set; } + + [Params(100, 200, 1_000)] + public int OperationsPerInvoke { get; set; } + + [GlobalSetup] + public void Setup() + { + SentryOptions options = new() + { + Dsn = DsnSamples.ValidDsn, + Experimental = + { + EnableLogs = true, + }, + }; + + var batchInterval = Timeout.InfiniteTimeSpan; + + var clientReportRecorder = Substitute.For(); + clientReportRecorder + .When(static recorder => recorder.RecordDiscardedEvent(Arg.Any(), Arg.Any(), Arg.Any())) + .Throw(); + + var diagnosticLogger = Substitute.For(); + diagnosticLogger + .When(static logger => logger.IsEnabled(Arg.Any())) + .Throw(); + diagnosticLogger + .When(static logger => logger.Log(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())) + .Throw(); + + _hub = new Hub(options, DisabledHub.Instance); + _batchProcessor = new StructuredLogBatchProcessor(_hub, BatchCount, batchInterval, clientReportRecorder, diagnosticLogger); + _log = new SentryLog(DateTimeOffset.Now, SentryId.Empty, SentryLogLevel.Trace, "message"); + } + + [Benchmark] + public void EnqueueAndFlush() + { + for (var i = 0; i < OperationsPerInvoke; i++) + { + _batchProcessor.Enqueue(_log); + } + _batchProcessor.Flush(); + } + + [GlobalCleanup] + public void Cleanup() + { + _batchProcessor.Dispose(); + _hub.Dispose(); + } +} diff --git a/samples/Sentry.Samples.AspNetCore.Basic/Program.cs b/samples/Sentry.Samples.AspNetCore.Basic/Program.cs index cb8a4da994..221a10293b 100644 --- a/samples/Sentry.Samples.AspNetCore.Basic/Program.cs +++ b/samples/Sentry.Samples.AspNetCore.Basic/Program.cs @@ -15,6 +15,9 @@ // Log debug information about the Sentry SDK options.Debug = true; #endif + + // This option enables Logs sent to Sentry. + options.Experimental.EnableLogs = true; }); var app = builder.Build(); diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 39027fa0ee..f1ae39b6ff 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -3,6 +3,7 @@ * - Error Monitoring (both handled and unhandled exceptions) * - Performance Tracing (Transactions / Spans) * - Release Health (Sessions) + * - Logs * - MSBuild integration for Source Context (see the csproj) * * For more advanced features of the SDK, see Sentry.Samples.Console.Customized. @@ -35,6 +36,20 @@ // This option tells Sentry to capture 100% of traces. You still need to start transactions and spans. options.TracesSampleRate = 1.0; + + // This option enables Sentry Logs created via SentrySdk.Logger. + options.Experimental.EnableLogs = true; + options.Experimental.SetBeforeSendLog(static log => + { + // A demonstration of how you can drop logs based on some attribute they have + if (log.TryGetAttribute("suppress", out var attribute) && attribute is true) + { + return null; + } + + // Drop logs with level Info + return log.Level is SentryLogLevel.Info ? null : log; + }); }); // This starts a new transaction and attaches it to the scope. @@ -58,6 +73,7 @@ async Task FirstFunction() var httpClient = new HttpClient(messageHandler, true); var html = await httpClient.GetStringAsync("https://example.com/"); WriteLine(html); + SentrySdk.Experimental.Logger.LogInfo("HTTP Request completed."); } async Task SecondFunction() @@ -77,6 +93,8 @@ async Task SecondFunction() // This is an example of capturing a handled exception. SentrySdk.CaptureException(exception); span.Finish(exception); + + SentrySdk.Experimental.Logger.LogError("Error with message: {0}", [exception.Message], static log => log.SetAttribute("method", nameof(SecondFunction))); } span.Finish(); @@ -90,6 +108,8 @@ async Task ThirdFunction() // Simulate doing some work await Task.Delay(100); + SentrySdk.Experimental.Logger.LogFatal("Crash imminent!", [], static log => log.SetAttribute("suppress", true)); + // This is an example of an unhandled exception. It will be captured automatically. throw new InvalidOperationException("Something happened that crashed the app!"); } diff --git a/samples/Sentry.Samples.ME.Logging/Program.cs b/samples/Sentry.Samples.ME.Logging/Program.cs index 809db165b4..0178235c03 100644 --- a/samples/Sentry.Samples.ME.Logging/Program.cs +++ b/samples/Sentry.Samples.ME.Logging/Program.cs @@ -23,7 +23,17 @@ // Optionally configure options: The default values are: options.MinimumBreadcrumbLevel = LogLevel.Information; // It requires at least this level to store breadcrumb options.MinimumEventLevel = LogLevel.Error; // This level or above will result in event sent to Sentry + options.ExperimentalLogging.MinimumLogLevel = LogLevel.Trace; // This level or above will result in log sent to Sentry + // This option enables Logs sent to Sentry. + options.Experimental.EnableLogs = true; + options.Experimental.SetBeforeSendLog(static log => + { + log.SetAttribute("attribute-key", "attribute-value"); + return log; + }); + + // TODO: AddLogEntryFilter // Don't keep as a breadcrumb or send events for messages of level less than Critical with exception of type DivideByZeroException options.AddLogEntryFilter((_, level, _, exception) => level < LogLevel.Critical && exception is DivideByZeroException); diff --git a/samples/Sentry.Samples.Maui/MauiProgram.cs b/samples/Sentry.Samples.Maui/MauiProgram.cs index 625632ecc2..909851b7d4 100644 --- a/samples/Sentry.Samples.Maui/MauiProgram.cs +++ b/samples/Sentry.Samples.Maui/MauiProgram.cs @@ -33,6 +33,7 @@ public static MauiApp CreateMauiApp() options.AttachScreenshot = true; options.Debug = true; + options.Experimental.EnableLogs = true; options.SampleRate = 1.0F; // The Sentry MVVM Community Toolkit integration automatically creates traces for async relay commands, diff --git a/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs b/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs new file mode 100644 index 0000000000..90cb033375 --- /dev/null +++ b/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Sentry.Extensions.Logging; +using Sentry.Infrastructure; + +namespace Sentry.AspNetCore; + +/// +/// Structured Logger Provider for Sentry. +/// +[ProviderAlias("SentryLogs")] +internal sealed class SentryAspNetCoreStructuredLoggerProvider : SentryStructuredLoggerProvider +{ + public SentryAspNetCoreStructuredLoggerProvider(IOptions options, IHub hub) + : this(options.Value, hub, SystemClock.Clock, CreateSdkVersion()) + { + } + + internal SentryAspNetCoreStructuredLoggerProvider(SentryAspNetCoreOptions options, IHub hub, ISystemClock clock, SdkVersion sdk) + : base(options, hub, clock, sdk) + { + } + + private static SdkVersion CreateSdkVersion() + { + return new SdkVersion + { + Name = Constants.SdkName, + Version = SentryMiddleware.NameAndVersion.Version, + }; + } +} diff --git a/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs b/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs index c00217368b..2b5f74bf4d 100644 --- a/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs +++ b/src/Sentry.AspNetCore/SentryWebHostBuilderExtensions.cs @@ -93,10 +93,16 @@ public static IWebHostBuilder UseSentry( _ = logging.Services .AddSingleton, SentryAspNetCoreOptionsSetup>(); _ = logging.Services.AddSingleton(); + _ = logging.Services.AddSingleton(); _ = logging.AddFilter( "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware", LogLevel.None); + _ = logging.AddFilter(static (string? categoryName, LogLevel logLevel) => + { + return categoryName is null + || (categoryName != "Sentry.ISentryClient" && categoryName != "Sentry.AspNetCore.SentryMiddleware"); + }); var sentryBuilder = logging.Services.AddSentry(); configureSentry?.Invoke(context, sentryBuilder); diff --git a/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs b/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs index 299e61a21c..7843ad9975 100644 --- a/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs +++ b/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs @@ -9,11 +9,20 @@ internal class BindableSentryLoggingOptions : BindableSentryOptions public LogLevel? MinimumEventLevel { get; set; } public bool? InitializeSdk { get; set; } + public BindableSentryLoggingExperimentalOptions ExperimentalLogging { get; set; } = new(); + + internal sealed class BindableSentryLoggingExperimentalOptions + { + public LogLevel? MinimumLogLevel { get; set; } + } + public void ApplyTo(SentryLoggingOptions options) { base.ApplyTo(options); options.MinimumBreadcrumbLevel = MinimumBreadcrumbLevel ?? options.MinimumBreadcrumbLevel; options.MinimumEventLevel = MinimumEventLevel ?? options.MinimumEventLevel; options.InitializeSdk = InitializeSdk ?? options.InitializeSdk; + + options.ExperimentalLogging.MinimumLogLevel = ExperimentalLogging.MinimumLogLevel ?? options.ExperimentalLogging.MinimumLogLevel; } } diff --git a/src/Sentry.Extensions.Logging/LogLevelExtensions.cs b/src/Sentry.Extensions.Logging/LogLevelExtensions.cs index e3f862de77..9f1d2f85e2 100644 --- a/src/Sentry.Extensions.Logging/LogLevelExtensions.cs +++ b/src/Sentry.Extensions.Logging/LogLevelExtensions.cs @@ -45,4 +45,19 @@ public static SentryLevel ToSentryLevel(this LogLevel level) _ => SentryLevel.Debug }; } + + public static SentryLogLevel ToSentryLogLevel(this LogLevel logLevel) + { + return logLevel switch + { + LogLevel.Trace => SentryLogLevel.Trace, + LogLevel.Debug => SentryLogLevel.Debug, + LogLevel.Information => SentryLogLevel.Info, + LogLevel.Warning => SentryLogLevel.Warning, + LogLevel.Error => SentryLogLevel.Error, + LogLevel.Critical => SentryLogLevel.Fatal, + LogLevel.None => default, + _ => default, + }; + } } diff --git a/src/Sentry.Extensions.Logging/LoggingBuilderExtensions.cs b/src/Sentry.Extensions.Logging/LoggingBuilderExtensions.cs index 9b79803de0..f2a4957c11 100644 --- a/src/Sentry.Extensions.Logging/LoggingBuilderExtensions.cs +++ b/src/Sentry.Extensions.Logging/LoggingBuilderExtensions.cs @@ -51,6 +51,7 @@ internal static ILoggingBuilder AddSentry( builder.Services.AddSingleton, SentryLoggingOptionsSetup>(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSentry(); // All logs should flow to the SentryLogger, regardless of level. @@ -58,6 +59,14 @@ internal static ILoggingBuilder AddSentry( // Filtering of breadcrumbs is handled in SentryLogger, using SentryOptions.MinimumBreadcrumbLevel builder.AddFilter(_ => true); + // Logs from the SentryLogger should not flow to the SentryStructuredLogger as this may cause recursive invocations. + // Filtering of logs is handled in SentryStructuredLogger, using SentryOptions.MinimumLogLevel + builder.AddFilter(static (string? categoryName, LogLevel logLevel) => + { + return categoryName is null + || categoryName != "Sentry.ISentryClient"; + }); + return builder; } } diff --git a/src/Sentry.Extensions.Logging/Sentry.Extensions.Logging.csproj b/src/Sentry.Extensions.Logging/Sentry.Extensions.Logging.csproj index e011099d02..4d52cd091c 100644 --- a/src/Sentry.Extensions.Logging/Sentry.Extensions.Logging.csproj +++ b/src/Sentry.Extensions.Logging/Sentry.Extensions.Logging.csproj @@ -40,6 +40,7 @@ + diff --git a/src/Sentry.Extensions.Logging/SentryLoggingOptions.cs b/src/Sentry.Extensions.Logging/SentryLoggingOptions.cs index 52bd4a0260..d181b645bf 100644 --- a/src/Sentry.Extensions.Logging/SentryLoggingOptions.cs +++ b/src/Sentry.Extensions.Logging/SentryLoggingOptions.cs @@ -11,7 +11,9 @@ public class SentryLoggingOptions : SentryOptions /// /// Gets or sets the minimum breadcrumb level. /// - /// Events with this level or higher will be stored as + /// + /// Events with this level or higher will be stored as . + /// /// /// The minimum breadcrumb level. /// @@ -21,7 +23,7 @@ public class SentryLoggingOptions : SentryOptions /// Gets or sets the minimum event level. /// /// - /// Events with this level or higher will be sent to Sentry + /// Events with this level or higher will be sent to Sentry. /// /// /// The minimum event level. @@ -48,4 +50,39 @@ public class SentryLoggingOptions : SentryOptions /// List of callbacks to be invoked when initializing the SDK /// internal Action[] ConfigureScopeCallbacks { get; set; } = Array.Empty>(); + + /// + /// Experimental Sentry Logging features. + /// + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public SentryLoggingExperimentalOptions ExperimentalLogging { get; set; } = new(); + + /// + /// Experimental Sentry Logging options. + /// + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public sealed class SentryLoggingExperimentalOptions + { + internal SentryLoggingExperimentalOptions() + { + } + + /// + /// Gets or sets the minimum log level. + /// This API is experimental and it may change in the future. + /// + /// + /// Logs with this level or higher will be stored as . + /// + /// + /// The minimum log level. + /// + public LogLevel MinimumLogLevel { get; set; } = LogLevel.Trace; + } } diff --git a/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs b/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs new file mode 100644 index 0000000000..87a0daae49 --- /dev/null +++ b/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs @@ -0,0 +1,121 @@ +using Microsoft.Extensions.Logging; +using Sentry.Extensibility; +using Sentry.Infrastructure; + +namespace Sentry.Extensions.Logging; + +internal sealed class SentryStructuredLogger : ILogger +{ + private readonly string? _categoryName; + private readonly SentryLoggingOptions _options; + private readonly IHub _hub; + private readonly ISystemClock _clock; + private readonly SdkVersion _sdk; + + internal SentryStructuredLogger(string categoryName, SentryLoggingOptions options, IHub hub, ISystemClock clock, SdkVersion sdk) + { + _categoryName = categoryName; + _options = options; + _clock = clock; + _hub = hub; + _sdk = sdk; + } + + public IDisposable? BeginScope(TState state) where TState : notnull + { + return NullDisposable.Instance; + } + + public bool IsEnabled(LogLevel logLevel) + { + return _hub.IsEnabled + && _options.Experimental.EnableLogs + && logLevel != LogLevel.None + && logLevel >= _options.ExperimentalLogging.MinimumLogLevel; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var timestamp = _clock.GetUtcNow(); + var traceHeader = _hub.GetTraceHeader() ?? SentryTraceHeader.Empty; + + var level = logLevel.ToSentryLogLevel(); + Debug.Assert(level != default); + + string message; + try + { + message = formatter.Invoke(state, exception); + } + catch (FormatException e) + { + _options.DiagnosticLogger?.LogError(e, "Template string does not match the provided argument. The Log will be dropped."); + return; + } + + string? template = null; + var parameters = ImmutableArray.CreateBuilder>(); + // see Microsoft.Extensions.Logging.FormattedLogValues + if (state is IReadOnlyList> formattedLogValues) + { + if (formattedLogValues.Count != 0) + { + parameters.Capacity = formattedLogValues.Count - 1; + } + + foreach (var formattedLogValue in formattedLogValues) + { + if (formattedLogValue.Key == "{OriginalFormat}" && formattedLogValue.Value is string formattedString) + { + template = formattedString; + } + else if (formattedLogValue.Value is not null) + { + parameters.Add(new KeyValuePair(formattedLogValue.Key, formattedLogValue.Value)); + } + } + } + + SentryLog log = new(timestamp, traceHeader.TraceId, level, message) + { + Template = template, + Parameters = parameters.DrainToImmutable(), + ParentSpanId = traceHeader.SpanId, + }; + + log.SetDefaultAttributes(_options, _sdk); + + if (_categoryName is not null) + { + log.SetAttribute("microsoft.extensions.logging.category_name", _categoryName); + } + if (eventId.Name is not null || eventId.Id != 0) + { + log.SetAttribute("microsoft.extensions.logging.event.id", eventId.Id); + } + if (eventId.Name is not null) + { + log.SetAttribute("microsoft.extensions.logging.event.name", eventId.Name); + } + + _hub.Logger.CaptureLog(log); + } +} + +file sealed class NullDisposable : IDisposable +{ + public static NullDisposable Instance { get; } = new NullDisposable(); + + private NullDisposable() + { + } + + public void Dispose() + { + } +} diff --git a/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs b/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs new file mode 100644 index 0000000000..fdfe0a527c --- /dev/null +++ b/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Sentry.Infrastructure; + +namespace Sentry.Extensions.Logging; + +/// +/// Sentry Structured Logger Provider. +/// +[ProviderAlias("SentryLogs")] +internal class SentryStructuredLoggerProvider : ILoggerProvider +{ + private readonly SentryLoggingOptions _options; + private readonly IHub _hub; + private readonly ISystemClock _clock; + private readonly SdkVersion _sdk; + + public SentryStructuredLoggerProvider(IOptions options, IHub hub) + : this(options.Value, hub, SystemClock.Clock, CreateSdkVersion()) + { + } + + internal SentryStructuredLoggerProvider(SentryLoggingOptions options, IHub hub, ISystemClock clock, SdkVersion sdk) + { + _options = options; + _hub = hub; + _clock = clock; + _sdk = sdk; + } + + public ILogger CreateLogger(string categoryName) + { + return new SentryStructuredLogger(categoryName, _options, _hub, _clock, _sdk); + } + + public void Dispose() + { + } + + private static SdkVersion CreateSdkVersion() + { + return new SdkVersion + { + Name = Constants.SdkName, + Version = SentryLoggerProvider.NameAndVersion.Version, + }; + } +} diff --git a/src/Sentry.Maui/Internal/SentryMauiStructuredLoggerProvider.cs b/src/Sentry.Maui/Internal/SentryMauiStructuredLoggerProvider.cs new file mode 100644 index 0000000000..d0525a4b49 --- /dev/null +++ b/src/Sentry.Maui/Internal/SentryMauiStructuredLoggerProvider.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Sentry.Extensions.Logging; +using Sentry.Infrastructure; + +namespace Sentry.Maui.Internal; + +[ProviderAlias("SentryLogs")] +internal sealed class SentryMauiStructuredLoggerProvider : SentryStructuredLoggerProvider +{ + public SentryMauiStructuredLoggerProvider(IOptions options, IHub hub) + : this(options.Value, hub, SystemClock.Clock, CreateSdkVersion()) + { + } + + internal SentryMauiStructuredLoggerProvider(SentryMauiOptions options, IHub hub, ISystemClock clock, SdkVersion sdk) + : base(options, hub, clock, sdk) + { + } + + private static SdkVersion CreateSdkVersion() + { + return new SdkVersion + { + Name = Constants.SdkName, + Version = Constants.SdkVersion, + }; + } +} diff --git a/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs b/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs index 38c51d6281..e9cbd6d39d 100644 --- a/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs +++ b/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs @@ -52,10 +52,17 @@ public static MauiAppBuilder UseSentry(this MauiAppBuilder builder, services.AddLogging(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton, SentryMauiOptionsSetup>(); services.AddSingleton(); + builder.Logging.AddFilter(static (string? categoryName, LogLevel logLevel) => + { + return categoryName is null + || categoryName != "Sentry.ISentryClient"; + }); + // Add default event binders services.AddSingleton(); services.AddSingleton(); diff --git a/src/Sentry/BindableSentryOptions.cs b/src/Sentry/BindableSentryOptions.cs index cd9e5cc8d8..9ca3847e1e 100644 --- a/src/Sentry/BindableSentryOptions.cs +++ b/src/Sentry/BindableSentryOptions.cs @@ -53,6 +53,13 @@ internal partial class BindableSentryOptions public bool? EnableSpotlight { get; set; } public string? SpotlightUrl { get; set; } + public BindableSentryExperimentalOptions Experimental { get; set; } = new(); + + internal sealed class BindableSentryExperimentalOptions + { + public bool? EnableLogs { get; set; } + } + public void ApplyTo(SentryOptions options) { options.IsGlobalModeEnabled = IsGlobalModeEnabled ?? options.IsGlobalModeEnabled; @@ -100,6 +107,8 @@ public void ApplyTo(SentryOptions options) options.EnableSpotlight = EnableSpotlight ?? options.EnableSpotlight; options.SpotlightUrl = SpotlightUrl ?? options.SpotlightUrl; + options.Experimental.EnableLogs = Experimental.EnableLogs ?? options.Experimental.EnableLogs; + #if ANDROID Android.ApplyTo(options.Android); Native.ApplyTo(options.Native); diff --git a/src/Sentry/Extensibility/DiagnosticLoggerExtensions.cs b/src/Sentry/Extensibility/DiagnosticLoggerExtensions.cs index 7c3a2e5b6b..3a51399539 100644 --- a/src/Sentry/Extensibility/DiagnosticLoggerExtensions.cs +++ b/src/Sentry/Extensibility/DiagnosticLoggerExtensions.cs @@ -58,6 +58,17 @@ internal static void LogDebug( TArg2 arg2) => options.DiagnosticLogger?.LogIfEnabled(SentryLevel.Debug, null, message, arg, arg2); + /// + /// Log a debug message. + /// + public static void LogDebug( + this IDiagnosticLogger logger, + string message, + TArg arg, + TArg2 arg2, + TArg3 arg3) + => logger.LogIfEnabled(SentryLevel.Debug, null, message, arg, arg2, arg3); + /// /// Log a debug message. /// @@ -233,6 +244,17 @@ internal static void LogWarning( TArg2 arg2) => options.DiagnosticLogger?.LogIfEnabled(SentryLevel.Warning, null, message, arg, arg2); + /// + /// Log a warning message. + /// + public static void LogWarning( + this IDiagnosticLogger logger, + string message, + TArg arg, + TArg2 arg2, + TArg3 arg3) + => logger.LogIfEnabled(SentryLevel.Warning, null, message, arg, arg2, arg3); + /// /// Log a warning message. /// diff --git a/src/Sentry/Extensibility/DisabledHub.cs b/src/Sentry/Extensibility/DisabledHub.cs index 339c295233..ad6165a50a 100644 --- a/src/Sentry/Extensibility/DisabledHub.cs +++ b/src/Sentry/Extensibility/DisabledHub.cs @@ -254,4 +254,11 @@ public void CaptureUserFeedback(UserFeedback userFeedback) /// No-Op. /// public SentryId LastEventId => SentryId.Empty; + + /// + /// Disabled Logger. + /// This API is experimental and it may change in the future. + /// + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public SentryStructuredLogger Logger => DisabledSentryStructuredLogger.Instance; } diff --git a/src/Sentry/Extensibility/HubAdapter.cs b/src/Sentry/Extensibility/HubAdapter.cs index c5953eeefa..132997cb5f 100644 --- a/src/Sentry/Extensibility/HubAdapter.cs +++ b/src/Sentry/Extensibility/HubAdapter.cs @@ -32,6 +32,13 @@ private HubAdapter() { } /// public SentryId LastEventId { [DebuggerStepThrough] get => SentrySdk.LastEventId; } + /// + /// Forwards the call to . + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public SentryStructuredLogger Logger { [DebuggerStepThrough] get => SentrySdk.Experimental.Logger; } + /// /// Forwards the call to . /// diff --git a/src/Sentry/HubExtensions.cs b/src/Sentry/HubExtensions.cs index 736c06ed12..eb233b2644 100644 --- a/src/Sentry/HubExtensions.cs +++ b/src/Sentry/HubExtensions.cs @@ -259,4 +259,16 @@ internal static ITransactionTracer StartTransaction( var transaction = hub.GetTransaction(); return transaction?.IsSampled == true ? transaction : null; } + + internal static Scope? GetScope(this IHub hub) + { + if (hub is Hub fullHub) + { + return fullHub.ScopeManager.GetCurrent().Key; + } + + Scope? current = null; + hub.ConfigureScope(scope => current = scope); + return current; + } } diff --git a/src/Sentry/IHub.cs b/src/Sentry/IHub.cs index abf722c89d..7232aea817 100644 --- a/src/Sentry/IHub.cs +++ b/src/Sentry/IHub.cs @@ -17,6 +17,20 @@ public interface IHub : ISentryClient, ISentryScopeManager /// public SentryId LastEventId { get; } + /// + /// Creates and sends logs to Sentry. + /// This API is experimental and it may change in the future. + /// + /// + /// Available options: + /// + /// + /// + /// + /// + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public SentryStructuredLogger Logger { get; } + /// /// Starts a transaction. /// diff --git a/src/Sentry/Infrastructure/DiagnosticId.cs b/src/Sentry/Infrastructure/DiagnosticId.cs index 92703ddc87..c5bd026784 100644 --- a/src/Sentry/Infrastructure/DiagnosticId.cs +++ b/src/Sentry/Infrastructure/DiagnosticId.cs @@ -2,10 +2,8 @@ namespace Sentry.Infrastructure; internal static class DiagnosticId { -#if NET5_0_OR_GREATER /// /// Indicates that the feature is experimental and may be subject to change or removal in future versions. /// internal const string ExperimentalFeature = "SENTRY0001"; -#endif } diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs new file mode 100644 index 0000000000..37b03babbd --- /dev/null +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -0,0 +1,117 @@ +using Sentry.Extensibility; +using Sentry.Infrastructure; + +namespace Sentry.Internal; + +internal sealed class DefaultSentryStructuredLogger : SentryStructuredLogger +{ + private readonly IHub _hub; + private readonly SentryOptions _options; + private readonly ISystemClock _clock; + + private readonly StructuredLogBatchProcessor _batchProcessor; + + internal DefaultSentryStructuredLogger(IHub hub, SentryOptions options, ISystemClock clock, int batchCount, TimeSpan batchInterval) + { + Debug.Assert(hub.IsEnabled); + Debug.Assert(options is { Experimental.EnableLogs: true }); + + _hub = hub; + _options = options; + _clock = clock; + + _batchProcessor = new StructuredLogBatchProcessor(hub, batchCount, batchInterval, _options.ClientReportRecorder, _options.DiagnosticLogger); + } + + /// + private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) + { + var timestamp = _clock.GetUtcNow(); + var traceHeader = _hub.GetTraceHeader() ?? SentryTraceHeader.Empty; + + string message; + try + { + message = string.Format(CultureInfo.InvariantCulture, template, parameters ?? []); + } + catch (FormatException e) + { + _options.DiagnosticLogger?.LogError(e, "Template string does not match the provided argument. The Log will be dropped."); + return; + } + + ImmutableArray> @params = default; + if (parameters is { Length: > 0 }) + { + var builder = ImmutableArray.CreateBuilder>(parameters.Length); + for (var index = 0; index < parameters.Length; index++) + { + builder.Add(new KeyValuePair(index.ToString(), parameters[index])); + } + @params = builder.DrainToImmutable(); + } + + SentryLog log = new(timestamp, traceHeader.TraceId, level, message) + { + Template = template, + Parameters = @params, + ParentSpanId = traceHeader.SpanId, + }; + + try + { + configureLog?.Invoke(log); + } + catch (Exception e) + { + _options.DiagnosticLogger?.LogError(e, "The configureLog callback threw an exception. The Log will be dropped."); + return; + } + + var scope = _hub.GetScope(); + log.SetDefaultAttributes(_options, scope?.Sdk ?? SdkVersion.Instance); + + CaptureLog(log); + } + + /// + protected internal override void CaptureLog(SentryLog log) + { + var configuredLog = log; + + if (_options.Experimental.BeforeSendLogInternal is { } beforeSendLog) + { + try + { + configuredLog = beforeSendLog.Invoke(log); + } + catch (Exception e) + { + _options.DiagnosticLogger?.LogError(e, "The BeforeSendLog callback threw an exception. The Log will be dropped."); + return; + } + } + + if (configuredLog is not null) + { + _batchProcessor.Enqueue(configuredLog); + } + } + + /// + protected internal override void Flush() + { + _batchProcessor.Flush(); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _batchProcessor.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/src/Sentry/Internal/DisabledSentryStructuredLogger.cs b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs new file mode 100644 index 0000000000..02fb6fc8f1 --- /dev/null +++ b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs @@ -0,0 +1,28 @@ +namespace Sentry.Internal; + +internal sealed class DisabledSentryStructuredLogger : SentryStructuredLogger +{ + internal static DisabledSentryStructuredLogger Instance { get; } = new DisabledSentryStructuredLogger(); + + internal DisabledSentryStructuredLogger() + { + } + + /// + private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) + { + // disabled + } + + /// + protected internal override void CaptureLog(SentryLog log) + { + // disabled + } + + /// + protected internal override void Flush() + { + // disabled + } +} diff --git a/src/Sentry/Internal/DiscardReason.cs b/src/Sentry/Internal/DiscardReason.cs index 11a35fa2a3..afc71bd3e2 100644 --- a/src/Sentry/Internal/DiscardReason.cs +++ b/src/Sentry/Internal/DiscardReason.cs @@ -11,6 +11,7 @@ namespace Sentry.Internal; public static DiscardReason QueueOverflow = new("queue_overflow"); public static DiscardReason RateLimitBackoff = new("ratelimit_backoff"); public static DiscardReason SampleRate = new("sample_rate"); + public static DiscardReason Backpressure = new("backpressure"); private readonly string _value; diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index 629e96128a..af7de6635d 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -69,6 +69,8 @@ internal Hub( PushScope(); } + Logger = SentryStructuredLogger.Create(this, options, _clock); + #if MEMORY_DUMP_SUPPORTED if (options.HeapDumpOptions is not null) { @@ -829,6 +831,9 @@ public void Dispose() _memoryMonitor?.Dispose(); #endif + Logger.Flush(); + Logger.Dispose(); + try { CurrentClient.FlushAsync(_options.ShutdownTimeout).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -853,4 +858,6 @@ public void Dispose() } public SentryId LastEventId => CurrentScope.LastEventId; + + public SentryStructuredLogger Logger { get; } } diff --git a/src/Sentry/Internal/StructuredLogBatchBuffer.cs b/src/Sentry/Internal/StructuredLogBatchBuffer.cs new file mode 100644 index 0000000000..7e518253a6 --- /dev/null +++ b/src/Sentry/Internal/StructuredLogBatchBuffer.cs @@ -0,0 +1,309 @@ +using Sentry.Threading; + +namespace Sentry.Internal; + +/// +/// A wrapper over an , intended for reusable buffering. +/// +/// +/// Must be attempted to flush via when either the is reached, +/// or when the is exceeded. +/// +[DebuggerDisplay("Name = {Name}, Capacity = {Capacity}, Additions = {_additions}, AddCount = {AddCount}, IsDisposed = {_disposed}")] +internal sealed class StructuredLogBatchBuffer : IDisposable +{ + private readonly SentryLog[] _array; + private int _additions; + private readonly ScopedCountdownLock _addLock; + + private readonly Timer _timer; + private readonly TimeSpan _timeout; + private readonly Action _timeoutExceededAction; + + private volatile bool _disposed; + + /// + /// Create a new buffer. + /// + /// Length of the new buffer. + /// When the timeout exceeds after an item has been added and the not yet been exceeded, is invoked. + /// The operation to execute when the exceeds if the buffer is neither empty nor full. + /// Name of the new buffer. + public StructuredLogBatchBuffer(int capacity, TimeSpan timeout, Action timeoutExceededAction, string? name = null) + { + ThrowIfLessThanTwo(capacity, nameof(capacity)); + ThrowIfNegativeOrZero(timeout, nameof(timeout)); + + _array = new SentryLog[capacity]; + _additions = 0; + _addLock = new ScopedCountdownLock(); + + _timer = new Timer(OnIntervalElapsed, this, Timeout.Infinite, Timeout.Infinite); + _timeout = timeout; + _timeoutExceededAction = timeoutExceededAction; + + Name = name ?? "default"; + } + + /// + /// Name of the buffer. + /// + internal string Name { get; } + + /// + /// Maximum number of elements that can be added to the buffer. + /// + internal int Capacity => _array.Length; + + /// + /// Gets a value indicating whether this buffer is empty. + /// + internal bool IsEmpty => _additions == 0; + + /// + /// Number of operations in progress. + /// + private int AddCount => _addLock.Count; + + /// + /// Attempt to add one element to the buffer. + /// Is thread-safe. + /// + /// Element attempted to be added. + /// An describing the result of the thread-safe operation. + internal StructuredLogBatchBufferAddStatus Add(SentryLog item) + { + if (_disposed) + { + return StructuredLogBatchBufferAddStatus.IgnoredIsDisposed; + } + + using var scope = _addLock.TryEnterCounterScope(); + if (!scope.IsEntered) + { + return StructuredLogBatchBufferAddStatus.IgnoredIsFlushing; + } + + var count = Interlocked.Increment(ref _additions); + + if (count == 1) + { + EnableTimer(); + _array[count - 1] = item; + return StructuredLogBatchBufferAddStatus.AddedFirst; + } + + if (count < _array.Length) + { + _array[count - 1] = item; + return StructuredLogBatchBufferAddStatus.Added; + } + + if (count == _array.Length) + { + DisableTimer(); + _array[count - 1] = item; + return StructuredLogBatchBufferAddStatus.AddedLast; + } + + Debug.Assert(count > _array.Length); + return StructuredLogBatchBufferAddStatus.IgnoredCapacityExceeded; + } + + /// + /// Enters a used to ensure that only a single flush operation is in progress. + /// + /// + /// Must be disposed to exit. + /// + internal FlushScope TryEnterFlushScope() + { + if (_disposed) + { + return new FlushScope(); + } + + var scope = _addLock.TryEnterLockScope(); + if (scope.IsEntered) + { + return new FlushScope(this, scope); + } + + return new FlushScope(); + } + + /// + /// Exits the through . + /// + private void ExitFlushScope() + { + Debug.Assert(_addLock.IsEngaged); + } + + /// + /// Callback when Timer has elapsed after first item has been added and buffer is not full yet. + /// + internal void OnIntervalElapsed(object? state) + { + if (!_disposed) + { + _timeoutExceededAction(this); + } + } + + /// + /// Returns a new Array consisting of the elements successfully added. + /// + /// An Array with Length of successful additions. + private SentryLog[] ToArrayAndClear() + { + var additions = _additions; + var length = _array.Length; + if (additions < length) + { + length = additions; + } + return ToArrayAndClear(length); + } + + /// + /// Returns a new Array consisting of elements successfully added. + /// + /// The Length of the buffer a new Array is created from. + /// An Array with Length of . + private SentryLog[] ToArrayAndClear(int length) + { + Debug.Assert(_addLock.IsSet); + + var array = ToArray(length); + Clear(length); + return array; + } + + private SentryLog[] ToArray(int length) + { + if (length == 0) + { + return Array.Empty(); + } + + var array = new SentryLog[length]; + Array.Copy(_array, array, length); + return array; + } + + private void Clear(int length) + { + if (length == 0) + { + return; + } + + _additions = 0; + Array.Clear(_array, 0, length); + } + + private void EnableTimer() + { + _ = _timer.Change(_timeout, Timeout.InfiniteTimeSpan); + } + + private void DisableTimer() + { + _ = _timer.Change(Timeout.Infinite, Timeout.Infinite); + } + + /// + public void Dispose() + { + _timer.Dispose(); + _addLock.Dispose(); + _disposed = true; + } + + private static void ThrowIfLessThanTwo(int value, string paramName) + { + if (value < 2) + { + ThrowLessThanTwo(value, paramName); + } + + static void ThrowLessThanTwo(int value, string paramName) + { + throw new ArgumentOutOfRangeException(paramName, value, "Argument must be at least two."); + } + } + + private static void ThrowIfNegativeOrZero(TimeSpan value, string paramName) + { + if (value <= TimeSpan.Zero && value != Timeout.InfiniteTimeSpan) + { + ThrowNegativeOrZero(value, paramName); + } + + static void ThrowNegativeOrZero(TimeSpan value, string paramName) + { + throw new ArgumentOutOfRangeException(paramName, value, "Argument must be a non-negative and non-zero value."); + } + } + + /// + /// A scope than ensures only a single operation is in progress, + /// and blocks the calling thread until all operations have finished. + /// When is , no more can be started, + /// which will then return immediately. + /// + /// + /// Only when scope . + /// + internal ref struct FlushScope : IDisposable + { + private StructuredLogBatchBuffer? _lockObj; + private ScopedCountdownLock.LockScope _scope; + + internal FlushScope(StructuredLogBatchBuffer lockObj, ScopedCountdownLock.LockScope scope) + { + Debug.Assert(scope.IsEntered); + _lockObj = lockObj; + _scope = scope; + } + + internal bool IsEntered => _scope.IsEntered; + + internal SentryLog[] Flush() + { + var lockObj = _lockObj; + if (lockObj is not null) + { + _scope.Wait(); + + var array = lockObj.ToArrayAndClear(); + return array; + } + + throw new ObjectDisposedException(nameof(FlushScope)); + } + + public void Dispose() + { + var lockObj = _lockObj; + if (lockObj is not null) + { + _lockObj = null; + lockObj.ExitFlushScope(); + } + + _scope.Dispose(); + } + } +} + +internal enum StructuredLogBatchBufferAddStatus : byte +{ + AddedFirst, + Added, + AddedLast, + IgnoredCapacityExceeded, + IgnoredIsFlushing, + IgnoredIsDisposed, +} diff --git a/src/Sentry/Internal/StructuredLogBatchProcessor.cs b/src/Sentry/Internal/StructuredLogBatchProcessor.cs new file mode 100644 index 0000000000..2fe5db924e --- /dev/null +++ b/src/Sentry/Internal/StructuredLogBatchProcessor.cs @@ -0,0 +1,145 @@ +using Sentry.Extensibility; +using Sentry.Protocol; +using Sentry.Protocol.Envelopes; + +namespace Sentry.Internal; + +/// +/// The Batch Processor for Sentry Logs. +/// +/// +/// Uses a double buffer strategy to achieve synchronous and lock-free adding. +/// Switches the active buffer either when full or timeout exceeded (after first item added). +/// Logs are dropped when both buffers are either full or being flushed. +/// Logs are not enqueued when the Hub is disabled (Hub is being or has been disposed). +/// Flushing blocks the calling thread until all pending add operations have completed. +/// +/// Implementation: +/// - When Hub is disabled (i.e. disposed), does not enqueue log +/// - Try to enqueue log into currently active buffer +/// - when currently active buffer is full, try to enqueue log into the other buffer +/// - when the other buffer is also full, or currently being flushed, then the log is dropped and a discarded event is recorded as a client report +/// - Swap currently active buffer when +/// - buffer is full +/// - timeout has exceeded +/// - Batch and Capture logs after swapping currently active buffer +/// - wait until all pending add/enqueue operations have completed (required for timeout) +/// - flush the buffer and capture an envelope containing the batched logs +/// - After flush, logs can be enqueued again into the buffer +/// +/// +/// Sentry Logs +/// Sentry Batch Processor +/// OpenTelemetry Batch Processor +internal sealed class StructuredLogBatchProcessor : IDisposable +{ + private readonly IHub _hub; + private readonly IClientReportRecorder _clientReportRecorder; + private readonly IDiagnosticLogger? _diagnosticLogger; + + private readonly StructuredLogBatchBuffer _buffer1; + private readonly StructuredLogBatchBuffer _buffer2; + private volatile StructuredLogBatchBuffer _activeBuffer; + + public StructuredLogBatchProcessor(IHub hub, int batchCount, TimeSpan batchInterval, IClientReportRecorder clientReportRecorder, IDiagnosticLogger? diagnosticLogger) + { + _hub = hub; + _clientReportRecorder = clientReportRecorder; + _diagnosticLogger = diagnosticLogger; + + _buffer1 = new StructuredLogBatchBuffer(batchCount, batchInterval, OnTimeoutExceeded, "Buffer 1"); + _buffer2 = new StructuredLogBatchBuffer(batchCount, batchInterval, OnTimeoutExceeded, "Buffer 2"); + _activeBuffer = _buffer1; + } + + internal void Enqueue(SentryLog log) + { + if (!_hub.IsEnabled) + { + return; + } + + var activeBuffer = _activeBuffer; + + if (!TryEnqueue(activeBuffer, log)) + { + activeBuffer = ReferenceEquals(activeBuffer, _buffer1) ? _buffer2 : _buffer1; + if (!TryEnqueue(activeBuffer, log)) + { + _clientReportRecorder.RecordDiscardedEvent(DiscardReason.Backpressure, DataCategory.Default, 1); + _diagnosticLogger?.LogInfo("Log Buffer full ... dropping log"); + } + } + } + + internal void Flush() + { + CaptureLogs(_buffer1); + CaptureLogs(_buffer2); + } + + /// + /// Forces invocation of a Timeout of the active buffer. + /// + /// + /// Intended for Testing only. + /// + internal void OnIntervalElapsed() + { + var activeBuffer = _activeBuffer; + activeBuffer.OnIntervalElapsed(activeBuffer); + } + + private bool TryEnqueue(StructuredLogBatchBuffer buffer, SentryLog log) + { + var status = buffer.Add(log); + + if (status is StructuredLogBatchBufferAddStatus.AddedLast) + { + SwapActiveBuffer(buffer); + CaptureLogs(buffer); + return true; + } + + return status is StructuredLogBatchBufferAddStatus.AddedFirst or StructuredLogBatchBufferAddStatus.Added; + } + + private void SwapActiveBuffer(StructuredLogBatchBuffer currentActiveBuffer) + { + var newActiveBuffer = ReferenceEquals(currentActiveBuffer, _buffer1) ? _buffer2 : _buffer1; + _ = Interlocked.CompareExchange(ref _activeBuffer, newActiveBuffer, currentActiveBuffer); + } + + private void CaptureLogs(StructuredLogBatchBuffer buffer) + { + SentryLog[]? logs = null; + + using (var scope = buffer.TryEnterFlushScope()) + { + if (scope.IsEntered) + { + logs = scope.Flush(); + } + } + + if (logs is not null && logs.Length != 0) + { + _ = _hub.CaptureEnvelope(Envelope.FromLog(new StructuredLog(logs))); + } + } + + private void OnTimeoutExceeded(StructuredLogBatchBuffer buffer) + { + if (!buffer.IsEmpty) + { + SwapActiveBuffer(buffer); + CaptureLogs(buffer); + } + } + + public void Dispose() + { + _buffer1.Dispose(); + _buffer2.Dispose(); + } +} diff --git a/src/Sentry/Polyfilling/ImmutableCollectionsPolyfill.cs b/src/Sentry/Polyfilling/ImmutableCollectionsPolyfill.cs new file mode 100644 index 0000000000..68b44d5dcc --- /dev/null +++ b/src/Sentry/Polyfilling/ImmutableCollectionsPolyfill.cs @@ -0,0 +1,20 @@ +// ReSharper disable CheckNamespace +namespace System.Collections.Immutable; + +internal static class ImmutableCollectionsPolyfill +{ +#if !NET8_0_OR_GREATER + internal static ImmutableArray DrainToImmutable(this ImmutableArray.Builder builder) + { + if (builder.Capacity == builder.Count) + { + return builder.MoveToImmutable(); + } + + var result = builder.ToImmutable(); + builder.Count = 0; + builder.Capacity = 0; + return result; + } +#endif +} diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index 0c6dec0281..45193ea097 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -451,6 +451,18 @@ internal static Envelope FromClientReport(ClientReport clientReport) internal static Envelope FromAttachment(SentryId eventId, SentryAttachment attachment, IDiagnosticLogger? logger = null) => new(eventId, CreateHeader(eventId), [EnvelopeItem.FromAttachment(attachment)]); + internal static Envelope FromLog(StructuredLog log) + { + var header = DefaultHeader; + + var items = new[] + { + EnvelopeItem.FromLog(log), + }; + + return new Envelope(header, items); + } + private static async Task> DeserializeHeaderAsync( Stream stream, CancellationToken cancellationToken = default) diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index 7c721db581..5409ebc0a6 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -24,6 +24,7 @@ public sealed class EnvelopeItem : ISerializable, IDisposable internal const string TypeValueProfile = "profile"; internal const string TypeValueMetric = "statsd"; internal const string TypeValueCodeLocations = "metric_meta"; + internal const string TypeValueLog = "log"; private const string LengthKey = "length"; private const string FileNameKey = "filename"; @@ -370,6 +371,18 @@ internal static EnvelopeItem FromClientReport(ClientReport report) return new EnvelopeItem(header, new JsonSerializable(report)); } + internal static EnvelopeItem FromLog(StructuredLog log) + { + var header = new Dictionary(3, StringComparer.Ordinal) + { + [TypeKey] = TypeValueLog, + ["item_count"] = log.Length, + ["content_type"] = "application/vnd.sentry.items.log+json", + }; + + return new EnvelopeItem(header, new JsonSerializable(log)); + } + private static async Task> DeserializeHeaderAsync( Stream stream, CancellationToken cancellationToken = default) diff --git a/src/Sentry/Protocol/SentryAttribute.cs b/src/Sentry/Protocol/SentryAttribute.cs new file mode 100644 index 0000000000..4a509b5f59 --- /dev/null +++ b/src/Sentry/Protocol/SentryAttribute.cs @@ -0,0 +1,192 @@ +using Sentry.Extensibility; + +namespace Sentry.Protocol; + +[DebuggerDisplay(@"\{ Value = {Value}, Type = {Type} \}")] +internal readonly struct SentryAttribute +{ + internal static SentryAttribute CreateString(object value) => new(value, "string"); + internal static SentryAttribute CreateBoolean(object value) => new(value, "boolean"); + internal static SentryAttribute CreateInteger(object value) => new(value, "integer"); + internal static SentryAttribute CreateDouble(object value) => new(value, "double"); + + public SentryAttribute(object value) + { + Value = value; + Type = null; + } + + public SentryAttribute(object value, string type) + { + Value = value; + Type = type; + } + + public object? Value { get; } + public string? Type { get; } +} + +internal static class SentryAttributeSerializer +{ + internal static void WriteStringAttribute(Utf8JsonWriter writer, string propertyName, string value) + { + writer.WritePropertyName(propertyName); + writer.WriteStartObject(); + writer.WriteString("value", value); + writer.WriteString("type", "string"); + writer.WriteEndObject(); + } + + internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, SentryAttribute attribute, IDiagnosticLogger? logger) + { + if (attribute.Value is null) + { + logger?.LogWarning("'null' is not supported by Sentry-Attributes and will be ignored."); + return; + } + + writer.WritePropertyName(propertyName); + WriteAttributeValue(writer, attribute.Value, attribute.Type, logger); + } + + internal static void WriteAttribute(Utf8JsonWriter writer, string propertyName, object? value, IDiagnosticLogger? logger) + { + if (value is null) + { + logger?.LogWarning("'null' is not supported by Sentry-Attributes and will be ignored."); + return; + } + + writer.WritePropertyName(propertyName); + WriteAttributeValue(writer, value, logger); + } + + private static void WriteAttributeValue(Utf8JsonWriter writer, object value, string? type, IDiagnosticLogger? logger) + { + if (type == "string") + { + writer.WriteStartObject(); + writer.WriteString("value", (string)value); + writer.WriteString("type", type); + writer.WriteEndObject(); + } + else + { + WriteAttributeValue(writer, value, logger); + } + } + + private static void WriteAttributeValue(Utf8JsonWriter writer, object value, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + + // covering most built-in types of .NET with C# language support + // for `net7.0` or greater, we could utilize "Generic Math" in the future, if there is demand + // see documentation for supported types: https://develop.sentry.dev/sdk/telemetry/logs/ + if (value is string @string) + { + writer.WriteString("value", @string); + writer.WriteString("type", "string"); + } + else if (value is char @char) + { +#if NET7_0_OR_GREATER + writer.WriteString("value", new ReadOnlySpan(in @char)); +#elif (NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER) + writer.WriteString("value", MemoryMarshal.CreateReadOnlySpan(ref @char, 1)); +#else + writer.WriteString("value", @char.ToString(CultureInfo.InvariantCulture)); +#endif + writer.WriteString("type", "string"); + } + else if (value is bool boolean) + { + writer.WriteBoolean("value", boolean); + writer.WriteString("type", "boolean"); + } + else if (value is sbyte @sbyte) + { + writer.WriteNumber("value", @sbyte); + writer.WriteString("type", "integer"); + } + else if (value is byte @byte) + { + writer.WriteNumber("value", @byte); + writer.WriteString("type", "integer"); + } + else if (value is short int16) + { + writer.WriteNumber("value", int16); + writer.WriteString("type", "integer"); + } + else if (value is ushort uint16) + { + writer.WriteNumber("value", uint16); + writer.WriteString("type", "integer"); + } + else if (value is int int32) + { + writer.WriteNumber("value", int32); + writer.WriteString("type", "integer"); + } + else if (value is uint uint32) + { + writer.WriteNumber("value", uint32); + writer.WriteString("type", "integer"); + } + else if (value is long int64) + { + writer.WriteNumber("value", int64); + writer.WriteString("type", "integer"); + } + else if (value is ulong uint64) + { + writer.WriteString("value", uint64.ToString(NumberFormatInfo.InvariantInfo)); + writer.WriteString("type", "string"); + + logger?.LogWarning("Type 'ulong' (unsigned 64-bit integer) is not supported by Sentry-Attributes due to possible overflows. Using 'ToString' and type=string. Please use a supported numeric type instead. To suppress this message, convert the value of this Attribute to type string explicitly."); + } + else if (value is nint intPtr) + { + writer.WriteNumber("value", intPtr); + writer.WriteString("type", "integer"); + } + else if (value is nuint uintPtr) + { +#if NET5_0_OR_GREATER + writer.WriteString("value", uintPtr.ToString(NumberFormatInfo.InvariantInfo)); +#else + writer.WriteString("value", uintPtr.ToString()); +#endif + writer.WriteString("type", "string"); + + logger?.LogWarning("Type 'nuint' (unsigned platform-dependent integer) is not supported by Sentry-Attributes due to possible overflows on 64-bit processes. Using 'ToString' and type=string. Please use a supported numeric type instead. To suppress this message, convert the value of this Attribute to type string explicitly."); + } + else if (value is float single) + { + writer.WriteNumber("value", single); + writer.WriteString("type", "double"); + } + else if (value is double @double) + { + writer.WriteNumber("value", @double); + writer.WriteString("type", "double"); + } + else if (value is decimal @decimal) + { + writer.WriteString("value", @decimal.ToString(NumberFormatInfo.InvariantInfo)); + writer.WriteString("type", "string"); + + logger?.LogWarning("Type 'decimal' (128-bit floating-point) is not supported by Sentry-Attributes due to possible overflows. Using 'ToString' and type=string. Please use a supported numeric type instead. To suppress this message, convert the value of this Attribute to type string explicitly."); + } + else + { + writer.WriteString("value", value.ToString()); + writer.WriteString("type", "string"); + + logger?.LogWarning("Type '{0}' is not supported by Sentry-Attributes. Using 'ToString' and type=string. Please use a supported type instead. To suppress this message, convert the value of this Attribute to type string explicitly.", value.GetType()); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Sentry/Protocol/StructuredLog.cs b/src/Sentry/Protocol/StructuredLog.cs new file mode 100644 index 0000000000..6543d31ffc --- /dev/null +++ b/src/Sentry/Protocol/StructuredLog.cs @@ -0,0 +1,37 @@ +using Sentry.Extensibility; + +namespace Sentry.Protocol; + +/// +/// Represents the Sentry Log protocol. +/// +/// +/// Sentry Docs: . +/// Sentry Developer Documentation: . +/// +internal sealed class StructuredLog : ISentryJsonSerializable +{ + private readonly SentryLog[] _items; + + public StructuredLog(SentryLog[] logs) + { + _items = logs; + } + + public int Length => _items.Length; + public ReadOnlySpan Items => _items; + + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + writer.WriteStartArray("items"); + + foreach (var log in _items) + { + log.WriteTo(writer, logger); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } +} diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs new file mode 100644 index 0000000000..b506b9da6c --- /dev/null +++ b/src/Sentry/SentryLog.cs @@ -0,0 +1,259 @@ +using Sentry.Extensibility; +using Sentry.Infrastructure; +using Sentry.Protocol; + +namespace Sentry; + +/// +/// Represents the Sentry Log protocol. +/// This API is experimental and it may change in the future. +/// +[Experimental(DiagnosticId.ExperimentalFeature)] +public sealed class SentryLog +{ + private readonly Dictionary _attributes; + + [SetsRequiredMembers] + internal SentryLog(DateTimeOffset timestamp, SentryId traceId, SentryLogLevel level, string message) + { + Timestamp = timestamp; + TraceId = traceId; + Level = level; + Message = message; + // 7 is the number of built-in attributes, so we start with that. + _attributes = new Dictionary(7); + } + + /// + /// The timestamp of the log. + /// This API is experimental and it may change in the future. + /// + /// + /// Sent as seconds since the Unix epoch. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public required DateTimeOffset Timestamp { get; init; } + + /// + /// The trace id of the log. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public required SentryId TraceId { get; init; } + + /// + /// The severity level of the log. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public required SentryLogLevel Level { get; init; } + + /// + /// The formatted log message. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public required string Message { get; init; } + + /// + /// The parameterized template string. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public string? Template { get; init; } + + /// + /// The parameters to the template string. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public ImmutableArray> Parameters { get; init; } + + /// + /// The span id of the span that was active when the log was collected. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public SpanId? ParentSpanId { get; init; } + + /// + /// Gets the attribute value associated with the specified key. + /// This API is experimental and it may change in the future. + /// + /// + /// Returns if the contains an attribute with the specified key and it's value is not . + /// Otherwise . + /// Supported types: + /// + /// + /// Type + /// Range + /// + /// + /// string + /// and + /// + /// + /// boolean + /// and + /// + /// + /// integer + /// 64-bit signed integral numeric types + /// + /// + /// double + /// 64-bit floating-point numeric types + /// + /// + /// Unsupported types: + /// + /// + /// Type + /// Result + /// + /// + /// + /// ToString as "type": "string" + /// + /// + /// Collections + /// ToString as "type": "string" + /// + /// + /// + /// ignored + /// + /// + /// + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public bool TryGetAttribute(string key, [NotNullWhen(true)] out object? value) + { + if (_attributes.TryGetValue(key, out var attribute) && attribute.Value is not null) + { + value = attribute.Value; + return true; + } + + value = null; + return false; + } + + internal bool TryGetAttribute(string key, [NotNullWhen(true)] out string? value) + { + if (_attributes.TryGetValue(key, out var attribute) && attribute.Type == "string" && attribute.Value is not null) + { + value = (string)attribute.Value; + return true; + } + + value = null; + return false; + } + + /// + /// Set a key-value pair of data attached to the log. + /// This API is experimental and it may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public void SetAttribute(string key, object value) + { + _attributes[key] = new SentryAttribute(value); + } + + internal void SetAttribute(string key, string value) + { + _attributes[key] = new SentryAttribute(value, "string"); + } + + internal void SetAttribute(string key, char value) + { + _attributes[key] = new SentryAttribute(value.ToString(), "string"); + } + + internal void SetAttribute(string key, int value) + { + _attributes[key] = new SentryAttribute(value, "integer"); + } + + internal void SetDefaultAttributes(SentryOptions options, SdkVersion sdk) + { + var environment = options.SettingLocator.GetEnvironment(); + SetAttribute("sentry.environment", environment); + + var release = options.SettingLocator.GetRelease(); + if (release is not null) + { + SetAttribute("sentry.release", release); + } + + if (sdk.Name is { } name) + { + SetAttribute("sentry.sdk.name", name); + } + if (sdk.Version is { } version) + { + SetAttribute("sentry.sdk.version", version); + } + } + + internal void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + +#if NET9_0_OR_GREATER + writer.WriteNumber("timestamp", Timestamp.ToUnixTimeMilliseconds() / (double)TimeSpan.MillisecondsPerSecond); +#else + writer.WriteNumber("timestamp", Timestamp.ToUnixTimeMilliseconds() / 1_000.0); +#endif + + var (severityText, severityNumber) = Level.ToSeverityTextAndOptionalSeverityNumber(logger); + writer.WriteString("level", severityText); + + writer.WriteString("body", Message); + + writer.WritePropertyName("trace_id"); + TraceId.WriteTo(writer, logger); + + if (severityNumber.HasValue) + { + writer.WriteNumber("severity_number", severityNumber.Value); + } + + writer.WritePropertyName("attributes"); + writer.WriteStartObject(); + + if (Template is not null) + { + SentryAttributeSerializer.WriteStringAttribute(writer, "sentry.message.template", Template); + } + + if (!Parameters.IsDefault) + { + foreach (var parameter in Parameters) + { + SentryAttributeSerializer.WriteAttribute(writer, $"sentry.message.parameter.{parameter.Key}", parameter.Value, logger); + } + } + + foreach (var attribute in _attributes) + { + SentryAttributeSerializer.WriteAttribute(writer, attribute.Key, attribute.Value, logger); + } + + if (ParentSpanId.HasValue) + { + writer.WritePropertyName("sentry.trace.parent_span_id"); + writer.WriteStartObject(); + writer.WritePropertyName("value"); + ParentSpanId.Value.WriteTo(writer, logger); + writer.WriteString("type", "string"); + writer.WriteEndObject(); + } + + writer.WriteEndObject(); // attributes + + writer.WriteEndObject(); + } +} diff --git a/src/Sentry/SentryLogLevel.cs b/src/Sentry/SentryLogLevel.cs new file mode 100644 index 0000000000..9ccde83f0d --- /dev/null +++ b/src/Sentry/SentryLogLevel.cs @@ -0,0 +1,134 @@ +using Sentry.Extensibility; +using Sentry.Infrastructure; + +namespace Sentry; + +/// +/// The severity of the structured log. +/// This API is experimental and it may change in the future. +/// +/// +/// The named constants use the value of the lowest severity number per severity level: +/// +/// +/// SeverityNumber +/// SeverityText +/// +/// +/// 1-4 +/// Trace +/// +/// +/// 5-8 +/// Debug +/// +/// +/// 9-12 +/// Info +/// +/// +/// 13-16 +/// Warn +/// +/// +/// 17-20 +/// Error +/// +/// +/// 21-24 +/// Fatal +/// +/// +/// +/// +[Experimental(DiagnosticId.ExperimentalFeature)] +public enum SentryLogLevel +{ + /// + /// A fine-grained debugging event. + /// + Trace = 1, + /// + /// A debugging event. + /// + Debug = 5, + /// + /// An informational event. + /// + Info = 9, + /// + /// A warning event. + /// + Warning = 13, + /// + /// An error event. + /// + Error = 17, + /// + /// A fatal error such as application or system crash. + /// + Fatal = 21, +} + +internal static class SentryLogLevelExtensions +{ + internal static (string, int?) ToSeverityTextAndOptionalSeverityNumber(this SentryLogLevel level, IDiagnosticLogger? logger) + { + return (int)level switch + { + <= 0 => Underflow(level, logger), + 1 => ("trace", null), + >= 2 and <= 4 => ("trace", (int)level), + 5 => ("debug", null), + >= 6 and <= 8 => ("debug", (int)level), + 9 => ("info", null), + >= 10 and <= 12 => ("info", (int)level), + 13 => ("warn", null), + >= 14 and <= 16 => ("warn", (int)level), + 17 => ("error", null), + >= 18 and <= 20 => ("error", (int)level), + 21 => ("fatal", null), + >= 22 and <= 24 => ("fatal", (int)level), + >= 25 => Overflow(level, logger), + }; + + static (string, int?) Underflow(SentryLogLevel level, IDiagnosticLogger? logger) + { + logger?.LogDebug("Log level {0} out of range ... clamping to minimum value {1} ({2})", level, 1, "trace"); + return ("trace", 1); + } + + static (string, int?) Overflow(SentryLogLevel level, IDiagnosticLogger? logger) + { + logger?.LogDebug("Log level {0} out of range ... clamping to maximum value {1} ({2})", level, 24, "fatal"); + return ("fatal", 24); + } + } + + internal static SentryLogLevel FromValue(int value, IDiagnosticLogger? logger) + { + return value switch + { + <= 0 => Underflow(value, logger), + >= 1 and <= 4 => SentryLogLevel.Trace, + >= 5 and <= 8 => SentryLogLevel.Debug, + >= 9 and <= 12 => SentryLogLevel.Info, + >= 13 and <= 16 => SentryLogLevel.Warning, + >= 17 and <= 20 => SentryLogLevel.Error, + >= 21 and <= 24 => SentryLogLevel.Fatal, + >= 25 => Overflow(value, logger), + }; + + static SentryLogLevel Underflow(int value, IDiagnosticLogger? logger) + { + logger?.LogDebug("Log number {0} out of range ... clamping to minimum level {1}", value, SentryLogLevel.Trace); + return SentryLogLevel.Trace; + } + + static SentryLogLevel Overflow(int value, IDiagnosticLogger? logger) + { + logger?.LogDebug("Log number {0} out of range ... clamping to maximum level {1}", value, SentryLogLevel.Fatal); + return SentryLogLevel.Fatal; + } + } +} diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index b39fc6df19..df4e2937e3 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -1857,4 +1857,54 @@ internal static List GetDefaultInAppExclude() => "ServiceStack", "Java.Interop", ]; + + /// + /// Experimental Sentry features. + /// + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public SentryExperimentalOptions Experimental { get; set; } = new(); + + /// + /// Experimental Sentry SDK options. + /// + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public sealed class SentryExperimentalOptions + { + internal SentryExperimentalOptions() + { + } + + /// + /// When set to , logs are sent to Sentry. + /// Defaults to . + /// This API is experimental and it may change in the future. + /// + /// + public bool EnableLogs { get; set; } = false; + + private Func? _beforeSendLog; + + internal Func? BeforeSendLogInternal => _beforeSendLog; + + /// + /// Sets a callback function to be invoked before sending the log to Sentry. + /// When the delegate throws an during invocation, the log will not be captured. + /// This API is experimental and it may change in the future. + /// + /// + /// It can be used to modify the log object before being sent to Sentry. + /// To prevent the log from being sent to Sentry, return . + /// + /// + public void SetBeforeSendLog(Func beforeSendLog) + { + _beforeSendLog = beforeSendLog; + } + } } diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index 6b487517a1..eab7b9838f 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -284,6 +284,19 @@ public void Dispose() /// public static bool IsEnabled { [DebuggerStepThrough] get => CurrentHub.IsEnabled; } + /// + /// Experimental Sentry SDK features. + /// + /// + /// This and related experimental APIs may change in the future. + /// + [Experimental(DiagnosticId.ExperimentalFeature)] + public static class Experimental + { + /// + public static SentryStructuredLogger Logger { [DebuggerStepThrough] get => CurrentHub.Logger; } + } + /// /// Creates a new scope that will terminate when disposed. /// diff --git a/src/Sentry/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs new file mode 100644 index 0000000000..0170c28254 --- /dev/null +++ b/src/Sentry/SentryStructuredLogger.cs @@ -0,0 +1,141 @@ +using Sentry.Infrastructure; +using Sentry.Internal; + +namespace Sentry; + +/// +/// Creates and sends logs to Sentry. +/// This API is experimental and it may change in the future. +/// +[Experimental(DiagnosticId.ExperimentalFeature)] +public abstract class SentryStructuredLogger : IDisposable +{ + internal static SentryStructuredLogger Create(IHub hub, SentryOptions options, ISystemClock clock) + => Create(hub, options, clock, 100, TimeSpan.FromSeconds(5)); + + internal static SentryStructuredLogger Create(IHub hub, SentryOptions options, ISystemClock clock, int batchCount, TimeSpan batchInterval) + { + return options.Experimental.EnableLogs + ? new DefaultSentryStructuredLogger(hub, options, clock, batchCount, batchInterval) + : DisabledSentryStructuredLogger.Instance; + } + + private protected SentryStructuredLogger() + { + } + + /// + /// Buffers a Sentry Log message + /// via the associated Batch Processor. + /// + /// The severity level of the log. + /// The parameterized template string. + /// The parameters to the string. + /// A configuration callback. Will be removed in a future version. + private protected abstract void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog); + + /// + /// Buffers a Sentry Log message + /// via the associated Batch Processor. + /// + /// The log. + protected internal abstract void CaptureLog(SentryLog log); + + /// + /// Clears all buffers for this logger and causes any buffered logs to be sent by the underlying . + /// + protected internal abstract void Flush(); + + /// + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. + [Experimental(DiagnosticId.ExperimentalFeature)] + public void LogTrace(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentryLogLevel.Trace, template, parameters, configureLog); + } + + /// + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. + [Experimental(DiagnosticId.ExperimentalFeature)] + public void LogDebug(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentryLogLevel.Debug, template, parameters, configureLog); + } + + /// + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. + [Experimental(DiagnosticId.ExperimentalFeature)] + public void LogInfo(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentryLogLevel.Info, template, parameters, configureLog); + } + + /// + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. + [Experimental(DiagnosticId.ExperimentalFeature)] + public void LogWarning(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentryLogLevel.Warning, template, parameters, configureLog); + } + + /// + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. + [Experimental(DiagnosticId.ExperimentalFeature)] + public void LogError(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentryLogLevel.Error, template, parameters, configureLog); + } + + /// + /// Creates and sends a structured log to Sentry, with severity , when enabled and sampled. + /// This API is experimental and it may change in the future. + /// + /// A formattable . When incompatible with the , the log will not be captured. See System.String.Format. + /// The arguments to the . See System.String.Format. + /// A delegate to set attributes on the . When the delegate throws an during invocation, the log will not be captured. + [Experimental(DiagnosticId.ExperimentalFeature)] + public void LogFatal(string template, object[]? parameters = null, Action? configureLog = null) + { + CaptureLog(SentryLogLevel.Fatal, template, parameters, configureLog); + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Override in inherited types to clean up managed and unmanaged resources. + /// + /// Invoked from when ; Invoked from Finalize when . + protected virtual void Dispose(bool disposing) + { + } +} diff --git a/src/Sentry/Threading/ScopedCountdownLock.cs b/src/Sentry/Threading/ScopedCountdownLock.cs new file mode 100644 index 0000000000..bc3725f1a9 --- /dev/null +++ b/src/Sentry/Threading/ScopedCountdownLock.cs @@ -0,0 +1,155 @@ +namespace Sentry.Threading; + +/// +/// A synchronization primitive that tracks the amount of s held, +/// and is signaled when the count reaches zero while a is held. +/// +/// +/// Similar to , +/// but allows to increment the current count after the count has reached zero without resetting to the initial count before a is entered. +/// Has a similar API shape to System.Threading.Lock. +/// +[DebuggerDisplay("IsSet = {IsSet}, Count = {Count}, IsEngaged = {IsEngaged}")] +internal sealed class ScopedCountdownLock : IDisposable +{ + private readonly CountdownEvent _event; + private volatile int _isEngaged; + + internal ScopedCountdownLock() + { + _event = new CountdownEvent(1); + _isEngaged = 0; + } + + /// + /// if the event is set/signaled; otherwise, . + /// When , the active can until the reaches . + /// + internal bool IsSet => _event.IsSet; + + /// + /// Gets the number of remaining required to exit in order to set/signal the event while a is active. + /// When and while a is active, no more can be entered. + /// + internal int Count => _isEngaged == 1 ? _event.CurrentCount : _event.CurrentCount - 1; + + /// + /// Returns when a is active and the event can be set/signaled by reaching . + /// Returns when the can only reach the initial count of when no is active any longer. + /// + internal bool IsEngaged => _isEngaged == 1; + + /// + /// No will be entered when the has reached while the lock is engaged via an active . + /// Check via whether the underlying has not been set/signaled yet. + /// To signal the underlying , ensure is called. + /// + /// + /// Must be disposed to exit. + /// + internal CounterScope TryEnterCounterScope() + { + if (_event.TryAddCount(1)) + { + return new CounterScope(this); + } + + return new CounterScope(); + } + + private void ExitCounterScope() + { + _ = _event.Signal(); + } + + /// + /// When successful, the lock , can reach when no is active, and the event can be set/signaled. + /// Check via whether the Lock . + /// Use to block until every active has exited before performing the locked operation. + /// After the locked operation has completed, disengage the Lock via . + /// + /// + /// Must be disposed to exit. + /// + internal LockScope TryEnterLockScope() + { + if (Interlocked.CompareExchange(ref _isEngaged, 1, 0) == 0) + { + _ = _event.Signal(); // decrement the initial count of 1, so that the event can be set with the count reaching 0 when all 'CounterScope's have exited + return new LockScope(this); + } + + return new LockScope(); + } + + private void ExitLockScope() + { + if (Interlocked.CompareExchange(ref _isEngaged, 0, 1) == 1) + { + _event.Reset(); // reset the signaled event to the initial count of 1, so that new `CounterScope`s can be entered again + return; + } + + Debug.Fail("The Lock should have not been disengaged without being engaged first."); + } + + /// + public void Dispose() + { + _event.Dispose(); + } + + internal ref struct CounterScope : IDisposable + { + private ScopedCountdownLock? _lockObj; + + internal CounterScope(ScopedCountdownLock lockObj) + { + _lockObj = lockObj; + } + + internal bool IsEntered => _lockObj is not null; + + public void Dispose() + { + var lockObj = _lockObj; + if (lockObj is not null) + { + _lockObj = null; + lockObj.ExitCounterScope(); + } + } + } + + internal ref struct LockScope : IDisposable + { + private ScopedCountdownLock? _lockObj; + + internal LockScope(ScopedCountdownLock lockObj) + { + _lockObj = lockObj; + } + + internal bool IsEntered => _lockObj is not null; + + /// + /// Blocks the current thread until the current reaches and the event is set/signaled. + /// The caller will return immediately if the event is currently in a set/signaled state. + /// + internal void Wait() + { + var lockObj = _lockObj; + lockObj?._event.Wait(); + } + + public void Dispose() + { + var lockObj = _lockObj; + if (lockObj is not null) + { + _lockObj = null; + lockObj.ExitLockScope(); + } + } + } +} diff --git a/test/Sentry.AspNetCore.Tests/SentryAspNetCoreStructuredLoggerProviderTests.cs b/test/Sentry.AspNetCore.Tests/SentryAspNetCoreStructuredLoggerProviderTests.cs new file mode 100644 index 0000000000..9edf8363ac --- /dev/null +++ b/test/Sentry.AspNetCore.Tests/SentryAspNetCoreStructuredLoggerProviderTests.cs @@ -0,0 +1,105 @@ +#nullable enable + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Sentry.AspNetCore.Tests; + +public class SentryAspNetCoreStructuredLoggerProviderTests +{ + private class Fixture + { + public IOptions Options { get; } + public IHub Hub { get; } + public MockClock Clock { get; } + public SdkVersion Sdk { get; } + + public Fixture() + { + var loggingOptions = new SentryAspNetCoreOptions(); + loggingOptions.Experimental.EnableLogs = true; + + Options = Microsoft.Extensions.Options.Options.Create(loggingOptions); + Hub = Substitute.For(); + Clock = new MockClock(); + Sdk = new SdkVersion + { + Name = "SDK Name", + Version = "SDK Version", + }; + + Hub.IsEnabled.Returns(true); + } + + public SentryAspNetCoreStructuredLoggerProvider GetSut() + { + return new SentryAspNetCoreStructuredLoggerProvider(Options.Value, Hub, Clock, Sdk); + } + } + + private readonly Fixture _fixture = new(); + + [Fact] + public void Ctor_DependencyInjection_CanCreate() + { + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Options) + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); + + var logger = services.GetRequiredService>(); + + logger.Should().BeOfType>(); + } + + [Fact] + public void CreateLogger_OfType() + { + var provider = _fixture.GetSut(); + + var logger = provider.CreateLogger("CategoryName"); + + logger.Should().BeOfType(); + } + + [Fact] + public void CreateLogger_DependencyInjection_CanLog() + { + SentryLog? capturedLog = null; + _fixture.Hub.Logger.Returns(Substitute.For()); + _fixture.Hub.Logger.CaptureLog(Arg.Do(log => capturedLog = log)); + + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Options) + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); + + var logger = services.GetRequiredService>(); + logger.LogInformation("message"); + + Assert.NotNull(capturedLog); + capturedLog.TryGetAttribute("microsoft.extensions.logging.category_name", out object? categoryName).Should().BeTrue(); + categoryName.Should().Be(typeof(SentryAspNetCoreStructuredLoggerProviderTests).FullName); + + capturedLog.TryGetAttribute("sentry.sdk.name", out object? name).Should().BeTrue(); + name.Should().Be(Constants.SdkName); + + capturedLog.TryGetAttribute("sentry.sdk.version", out object? version).Should().BeTrue(); + version.Should().Be(SentryMiddleware.NameAndVersion.Version); + } + + [Fact] + public void Dispose_NoOp() + { + var provider = _fixture.GetSut(); + + provider.Dispose(); + + provider.Dispose(); + } +} diff --git a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index b438b0af45..9112ddfffa 100644 --- a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -38,10 +38,17 @@ namespace Sentry.Extensions.Logging public class SentryLoggingOptions : Sentry.SentryOptions { public SentryLoggingOptions() { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.Extensions.Logging.SentryLoggingOptions.SentryLoggingExperimentalOptions ExperimentalLogging { get; set; } public bool InitializeSdk { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumBreadcrumbLevel { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumEventLevel { get; set; } public void ConfigureScope(System.Action action) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public sealed class SentryLoggingExperimentalOptions + { + public Microsoft.Extensions.Logging.LogLevel MinimumLogLevel { get; set; } + } } public static class SentryLoggingOptionsExtensions { diff --git a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index b438b0af45..9112ddfffa 100644 --- a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -38,10 +38,17 @@ namespace Sentry.Extensions.Logging public class SentryLoggingOptions : Sentry.SentryOptions { public SentryLoggingOptions() { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.Extensions.Logging.SentryLoggingOptions.SentryLoggingExperimentalOptions ExperimentalLogging { get; set; } public bool InitializeSdk { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumBreadcrumbLevel { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumEventLevel { get; set; } public void ConfigureScope(System.Action action) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public sealed class SentryLoggingExperimentalOptions + { + public Microsoft.Extensions.Logging.LogLevel MinimumLogLevel { get; set; } + } } public static class SentryLoggingOptionsExtensions { diff --git a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index b438b0af45..e4dd758823 100644 --- a/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Extensions.Logging.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -38,10 +38,15 @@ namespace Sentry.Extensions.Logging public class SentryLoggingOptions : Sentry.SentryOptions { public SentryLoggingOptions() { } + public Sentry.Extensions.Logging.SentryLoggingOptions.SentryLoggingExperimentalOptions ExperimentalLogging { get; set; } public bool InitializeSdk { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumBreadcrumbLevel { get; set; } public Microsoft.Extensions.Logging.LogLevel MinimumEventLevel { get; set; } public void ConfigureScope(System.Action action) { } + public sealed class SentryLoggingExperimentalOptions + { + public Microsoft.Extensions.Logging.LogLevel MinimumLogLevel { get; set; } + } } public static class SentryLoggingOptionsExtensions { diff --git a/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs index 778215de16..9321b3aa64 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsSetupTests.cs @@ -57,6 +57,8 @@ public void Configure_BindsConfigurationToOptions() MinimumEventLevel = LogLevel.Error, InitializeSdk = true }; + expected.Experimental.EnableLogs = true; + expected.ExperimentalLogging.MinimumLogLevel = LogLevel.None; var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { @@ -106,6 +108,9 @@ public void Configure_BindsConfigurationToOptions() ["MinimumBreadcrumbLevel"] = expected.MinimumBreadcrumbLevel.ToString(), ["MinimumEventLevel"] = expected.MinimumEventLevel.ToString(), ["InitializeSdk"] = expected.InitializeSdk.ToString(), + + ["Experimental:EnableLogs"] = expected.Experimental.EnableLogs.ToString(), + ["ExperimentalLogging:MinimumLogLevel"] = expected.ExperimentalLogging.MinimumLogLevel.ToString(), }) .Build(); @@ -163,6 +168,9 @@ public void Configure_BindsConfigurationToOptions() actual.MinimumBreadcrumbLevel.Should().Be(expected.MinimumBreadcrumbLevel); actual.MinimumEventLevel.Should().Be(expected.MinimumEventLevel); actual.InitializeSdk.Should().Be(expected.InitializeSdk); + + actual.Experimental.EnableLogs.Should().Be(expected.Experimental.EnableLogs); + actual.ExperimentalLogging.MinimumLogLevel.Should().Be(expected.ExperimentalLogging.MinimumLogLevel); } } } diff --git a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs new file mode 100644 index 0000000000..bd43dfc668 --- /dev/null +++ b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs @@ -0,0 +1,105 @@ +#nullable enable + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Sentry.Extensions.Logging.Tests; + +public class SentryStructuredLoggerProviderTests +{ + private class Fixture + { + public IOptions Options { get; } + public IHub Hub { get; } + public MockClock Clock { get; } + public SdkVersion Sdk { get; } + + public Fixture() + { + var loggingOptions = new SentryLoggingOptions(); + loggingOptions.Experimental.EnableLogs = true; + + Options = Microsoft.Extensions.Options.Options.Create(loggingOptions); + Hub = Substitute.For(); + Clock = new MockClock(); + Sdk = new SdkVersion + { + Name = "SDK Name", + Version = "SDK Version", + }; + + Hub.IsEnabled.Returns(true); + } + + public SentryStructuredLoggerProvider GetSut() + { + return new SentryStructuredLoggerProvider(Options.Value, Hub, Clock, Sdk); + } + } + + private readonly Fixture _fixture = new(); + + [Fact] + public void Ctor_DependencyInjection_CanCreate() + { + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Options) + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); + + var logger = services.GetRequiredService>(); + + logger.Should().BeOfType>(); + } + + [Fact] + public void CreateLogger_OfType() + { + var provider = _fixture.GetSut(); + + var logger = provider.CreateLogger("CategoryName"); + + logger.Should().BeOfType(); + } + + [Fact] + public void CreateLogger_DependencyInjection_CanLog() + { + SentryLog? capturedLog = null; + _fixture.Hub.Logger.Returns(Substitute.For()); + _fixture.Hub.Logger.CaptureLog(Arg.Do(log => capturedLog = log)); + + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Options) + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); + + var logger = services.GetRequiredService>(); + logger.LogInformation("message"); + + Assert.NotNull(capturedLog); + capturedLog.TryGetAttribute("microsoft.extensions.logging.category_name", out object? categoryName).Should().BeTrue(); + categoryName.Should().Be(typeof(SentryStructuredLoggerProviderTests).FullName); + + capturedLog.TryGetAttribute("sentry.sdk.name", out object? name).Should().BeTrue(); + name.Should().Be(Constants.SdkName); + + capturedLog.TryGetAttribute("sentry.sdk.version", out object? version).Should().BeTrue(); + version.Should().Be(SentryLoggerProvider.NameAndVersion.Version); + } + + [Fact] + public void Dispose_NoOp() + { + var provider = _fixture.GetSut(); + + provider.Dispose(); + + provider.Dispose(); + } +} diff --git a/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs new file mode 100644 index 0000000000..65878cdf93 --- /dev/null +++ b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs @@ -0,0 +1,317 @@ +#nullable enable + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Sentry.Extensions.Logging.Tests; + +public class SentryStructuredLoggerTests : IDisposable +{ + private class Fixture + { + public string CategoryName { get; internal set; } + public IOptions Options { get; } + public IHub Hub { get; } + public MockClock Clock { get; } + public SdkVersion Sdk { get; } + + public Queue CapturedLogs { get; } = new(); + public InMemoryDiagnosticLogger DiagnosticLogger { get; } = new(); + + public Fixture() + { + var loggingOptions = new SentryLoggingOptions + { + Debug = true, + DiagnosticLogger = DiagnosticLogger, + Environment = "my-environment", + Release = "my-release", + }; + + CategoryName = nameof(CategoryName); + Options = Microsoft.Extensions.Options.Options.Create(loggingOptions); + Hub = Substitute.For(); + Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, 789, TimeSpan.FromHours(2))); + Sdk = new SdkVersion + { + Name = "SDK Name", + Version = "SDK Version", + }; + + var logger = Substitute.For(); + logger.CaptureLog(Arg.Do(log => CapturedLogs.Enqueue(log))); + Hub.Logger.Returns(logger); + + EnableHub(true); + EnableLogs(true); + SetMinimumLogLevel(default); + } + + public void EnableHub(bool isEnabled) => Hub.IsEnabled.Returns(isEnabled); + public void EnableLogs(bool isEnabled) => Options.Value.Experimental.EnableLogs = isEnabled; + public void SetMinimumLogLevel(LogLevel logLevel) => Options.Value.ExperimentalLogging.MinimumLogLevel = logLevel; + + public void WithTraceHeader(SentryId traceId, SpanId parentSpanId) + { + var traceHeader = new SentryTraceHeader(traceId, parentSpanId, null); + Hub.GetTraceHeader().Returns(traceHeader); + } + + public SentryStructuredLogger GetSut() + { + return new SentryStructuredLogger(CategoryName, Options.Value, Hub, Clock, Sdk); + } + } + + private readonly Fixture _fixture = new(); + + public void Dispose() + { + _fixture.CapturedLogs.Should().BeEmpty(); + _fixture.DiagnosticLogger.Entries.Should().BeEmpty(); + } + + [Theory] + [InlineData(LogLevel.Trace, SentryLogLevel.Trace)] + [InlineData(LogLevel.Debug, SentryLogLevel.Debug)] + [InlineData(LogLevel.Information, SentryLogLevel.Info)] + [InlineData(LogLevel.Warning, SentryLogLevel.Warning)] + [InlineData(LogLevel.Error, SentryLogLevel.Error)] + [InlineData(LogLevel.Critical, SentryLogLevel.Fatal)] + [InlineData(LogLevel.None, default(SentryLogLevel))] + public void Log_LogLevel_CaptureLog(LogLevel logLevel, SentryLogLevel expectedLevel) + { + var traceId = SentryId.Create(); + var parentSpanId = SpanId.Create(); + _fixture.WithTraceHeader(traceId, parentSpanId); + var logger = _fixture.GetSut(); + + EventId eventId = new(123, "EventName"); + Exception? exception = new InvalidOperationException("message"); + string? message = "Message with {Argument}."; + object?[] args = ["argument"]; + + logger.Log(logLevel, eventId, exception, message, args); + + if (logLevel == LogLevel.None) + { + _fixture.CapturedLogs.Should().BeEmpty(); + return; + } + + var log = _fixture.CapturedLogs.Dequeue(); + log.Timestamp.Should().Be(_fixture.Clock.GetUtcNow()); + log.TraceId.Should().Be(traceId); + log.Level.Should().Be(expectedLevel); + log.Message.Should().Be("Message with argument."); + log.Template.Should().Be(message); + log.Parameters.Should().BeEquivalentTo(new KeyValuePair[] { new("Argument", "argument") }); + log.ParentSpanId.Should().Be(parentSpanId); + log.AssertAttribute("sentry.environment", "my-environment"); + log.AssertAttribute("sentry.release", "my-release"); + log.AssertAttribute("sentry.sdk.name", "SDK Name"); + log.AssertAttribute("sentry.sdk.version", "SDK Version"); + log.AssertAttribute("microsoft.extensions.logging.category_name", "CategoryName"); + log.AssertAttribute("microsoft.extensions.logging.event.id", 123); + log.AssertAttribute("microsoft.extensions.logging.event.name", "EventName"); + } + + [Fact] + public void Log_LogLevelNone_DoesNotCaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.None, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + _fixture.CapturedLogs.Should().BeEmpty(); + } + + [Fact] + public void Log_WithoutTraceHeader_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + var log = _fixture.CapturedLogs.Dequeue(); + log.TraceId.Should().Be(SentryTraceHeader.Empty.TraceId); + log.ParentSpanId.Should().Be(SentryTraceHeader.Empty.SpanId); + } + + [Fact] + public void Log_WithoutArguments_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message."); + + var log = _fixture.CapturedLogs.Dequeue(); + log.Message.Should().Be("Message."); + log.Template.Should().Be("Message."); + log.Parameters.Should().BeEmpty(); + } + + [Fact] + [SuppressMessage("Reliability", "CA2017:Parameter count mismatch", Justification = "Tests")] + [SuppressMessage("ReSharper", "StructuredMessageTemplateProblem", Justification = "Tests")] + public void Log_ParameterCountMismatch_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message with {Argument}."); + + var log = _fixture.CapturedLogs.Dequeue(); + log.Message.Should().Be("Message with {Argument}."); + log.Template.Should().Be("Message with {Argument}."); + log.Parameters.Should().BeEmpty(); + } + + [Fact] + [SuppressMessage("Reliability", "CA2017:Parameter count mismatch", Justification = "Tests")] + [SuppressMessage("ReSharper", "StructuredMessageTemplateProblem", Justification = "Tests")] + public void Log_ParameterCountMismatch_Throws() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message with {One}{Two}.", "One"); + + _fixture.CapturedLogs.Should().BeEmpty(); + var entry = _fixture.DiagnosticLogger.Dequeue(); + entry.Level.Should().Be(SentryLevel.Error); + entry.Message.Should().Be("Template string does not match the provided argument. The Log will be dropped."); + entry.Exception.Should().BeOfType(); + entry.Args.Should().BeEmpty(); + } + + [Fact] + public void Log_WithoutCategoryName_CaptureLog() + { + _fixture.CategoryName = null!; + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123, "EventName"), new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + var log = _fixture.CapturedLogs.Dequeue(); + log.TryGetAttribute("microsoft.extensions.logging.category_name", out object? _).Should().BeFalse(); + } + + [Fact] + public void Log_WithoutMessage_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new InvalidOperationException("message"), null, Array.Empty()); + + var log = _fixture.CapturedLogs.Dequeue(); + log.Message.Should().Be("[null]"); + log.Template.Should().Be("[null]"); + log.Parameters.Should().BeEmpty(); + } + + [Fact] + public void Log_WithoutEvent_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + var log = _fixture.CapturedLogs.Dequeue(); + log.TryGetAttribute("microsoft.extensions.logging.event.id", out object? _).Should().BeFalse(); + log.TryGetAttribute("microsoft.extensions.logging.event.name", out object? _).Should().BeFalse(); + } + + [Fact] + public void Log_WithoutEventId_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(0, "EventName"), new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + var log = _fixture.CapturedLogs.Dequeue(); + log.AssertAttribute("microsoft.extensions.logging.event.id", 0); + log.AssertAttribute("microsoft.extensions.logging.event.name", "EventName"); + } + + [Fact] + public void Log_WithoutEventName_CaptureLog() + { + var logger = _fixture.GetSut(); + + logger.Log(LogLevel.Information, new EventId(123), new InvalidOperationException("message"), "Message with {Argument}.", "argument"); + + var log = _fixture.CapturedLogs.Dequeue(); + log.AssertAttribute("microsoft.extensions.logging.event.id", 123); + log.TryGetAttribute("microsoft.extensions.logging.event.name", out object? _).Should().BeFalse(); + } + + [Theory] + [InlineData(true, true, LogLevel.Warning, LogLevel.Warning, true)] + [InlineData(false, true, LogLevel.Warning, LogLevel.Warning, false)] + [InlineData(true, false, LogLevel.Warning, LogLevel.Warning, false)] + [InlineData(true, true, LogLevel.Information, LogLevel.Warning, true)] + [InlineData(true, true, LogLevel.Error, LogLevel.Warning, false)] + public void IsEnabled_HubOptionsMinimumLogLevel_Returns(bool isHubEnabled, bool isLogsEnabled, LogLevel minimumLogLevel, LogLevel actualLogLevel, bool expectedIsEnabled) + { + _fixture.EnableHub(isHubEnabled); + _fixture.EnableLogs(isLogsEnabled); + _fixture.SetMinimumLogLevel(minimumLogLevel); + var logger = _fixture.GetSut(); + + var isEnabled = logger.IsEnabled(actualLogLevel); + logger.Log(actualLogLevel, "message"); + + isEnabled.Should().Be(expectedIsEnabled); + if (expectedIsEnabled) + { + _fixture.CapturedLogs.Dequeue().Message.Should().Be("message"); + } + } + + [Fact] + public void BeginScope_Dispose_NoOp() + { + var logger = _fixture.GetSut(); + + string messageFormat = "Message with {Argument}."; + object?[] args = ["argument"]; + + logger.LogInformation("one"); + using (var scope = logger.BeginScope(messageFormat, args)) + { + logger.LogInformation("two"); + } + logger.LogInformation("three"); + + _fixture.CapturedLogs.Dequeue().Message.Should().Be("one"); + _fixture.CapturedLogs.Dequeue().Message.Should().Be("two"); + _fixture.CapturedLogs.Dequeue().Message.Should().Be("three"); + } + + [Fact] + public void BeginScope_Shared_Same() + { + var logger = _fixture.GetSut(); + + using var scope1 = logger.BeginScope("Message with {Argument}.", "argument"); + using var scope2 = logger.BeginScope("Message with {Argument}.", "argument"); + + scope1.Should().BeSameAs(scope2); + } +} + +file static class SentryLogExtensions +{ + public static void AssertAttribute(this SentryLog log, string key, string value) + { + log.TryGetAttribute(key, out object? attribute).Should().BeTrue(); + var actual = attribute.Should().BeOfType().Which; + actual.Should().Be(value); + } + + public static void AssertAttribute(this SentryLog log, string key, int value) + { + log.TryGetAttribute(key, out object? attribute).Should().BeTrue(); + var actual = attribute.Should().BeOfType().Which; + actual.Should().Be(value); + } +} diff --git a/test/Sentry.Maui.Tests/Internal/SentryMauiStructuredLoggerProviderTests.cs b/test/Sentry.Maui.Tests/Internal/SentryMauiStructuredLoggerProviderTests.cs new file mode 100644 index 0000000000..1715498a5b --- /dev/null +++ b/test/Sentry.Maui.Tests/Internal/SentryMauiStructuredLoggerProviderTests.cs @@ -0,0 +1,105 @@ +#nullable enable + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Sentry.Maui.Internal; + +namespace Sentry.Maui.Tests.Internal; + +public class SentryMauiStructuredLoggerProviderTests +{ + private class Fixture + { + public IOptions Options { get; } + public IHub Hub { get; } + public MockClock Clock { get; } + public SdkVersion Sdk { get; } + + public Fixture() + { + var loggingOptions = new SentryMauiOptions(); + loggingOptions.Experimental.EnableLogs = true; + + Options = Microsoft.Extensions.Options.Options.Create(loggingOptions); + Hub = Substitute.For(); + Clock = new MockClock(); + Sdk = new SdkVersion + { + Name = "SDK Name", + Version = "SDK Version", + }; + + Hub.IsEnabled.Returns(true); + } + + public SentryMauiStructuredLoggerProvider GetSut() + { + return new SentryMauiStructuredLoggerProvider(Options.Value, Hub, Clock, Sdk); + } + } + + private readonly Fixture _fixture = new(); + + [Fact] + public void Ctor_DependencyInjection_CanCreate() + { + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Options) + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); + + var logger = services.GetRequiredService>(); + + logger.Should().BeOfType>(); + } + + [Fact] + public void CreateLogger_OfType() + { + var provider = _fixture.GetSut(); + + var logger = provider.CreateLogger("CategoryName"); + + logger.Should().BeOfType(); + } + + [Fact] + public void CreateLogger_DependencyInjection_CanLog() + { + SentryLog? capturedLog = null; + _fixture.Hub.Logger.Returns(Substitute.For()); + _fixture.Hub.Logger.CaptureLog(Arg.Do(log => capturedLog = log)); + + using var services = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton(_fixture.Options) + .AddSingleton(_fixture.Hub) + .BuildServiceProvider(); + + var logger = services.GetRequiredService>(); + logger.LogInformation("message"); + + Assert.NotNull(capturedLog); + capturedLog.TryGetAttribute("microsoft.extensions.logging.category_name", out object? categoryName).Should().BeTrue(); + categoryName.Should().Be(typeof(SentryMauiStructuredLoggerProviderTests).FullName); + + capturedLog.TryGetAttribute("sentry.sdk.name", out object? name).Should().BeTrue(); + name.Should().Be(Sentry.Maui.Internal.Constants.SdkName); + + capturedLog.TryGetAttribute("sentry.sdk.version", out object? version).Should().BeTrue(); + version.Should().Be(Sentry.Maui.Internal.Constants.SdkVersion); + } + + [Fact] + public void Dispose_NoOp() + { + var provider = _fixture.GetSut(); + + provider.Dispose(); + + provider.Dispose(); + } +} diff --git a/test/Sentry.Testing/BindableTests.cs b/test/Sentry.Testing/BindableTests.cs index 68dd553a36..b8baf13c8e 100644 --- a/test/Sentry.Testing/BindableTests.cs +++ b/test/Sentry.Testing/BindableTests.cs @@ -65,11 +65,25 @@ private static KeyValuePair GetDummyBindableValue(Property {$"key1", $"{propertyInfo.Name}value1"}, {$"key2", $"{propertyInfo.Name}value2"} }, + not null when propertyType == typeof(SentryOptions.SentryExperimentalOptions) => new SentryOptions.SentryExperimentalOptions + { + EnableLogs = true, + }, + not null when propertyType.FullName == "Sentry.Extensions.Logging.SentryLoggingOptions+SentryLoggingExperimentalOptions" => CreateSentryLoggingExperimentalOptions(), _ => throw new NotSupportedException($"Unsupported property type on property {propertyInfo.Name}") }; return new KeyValuePair(propertyInfo, value); } + private static object CreateSentryLoggingExperimentalOptions() + { + var options = Activator.CreateInstance("Sentry.Extensions.Logging", "Sentry.Extensions.Logging.SentryLoggingOptions+SentryLoggingExperimentalOptions", false, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.CreateInstance, null, null, null, null); + var instance = options.Unwrap(); + var property = instance.GetType().GetProperty("MinimumLogLevel", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + property.SetValue(instance, int.MaxValue); + return instance; + } + private static IEnumerable> ToConfigValues(KeyValuePair item) { var (prop, value) = item; @@ -81,6 +95,16 @@ private static IEnumerable> ToConfigValues(KeyValue yield return new KeyValuePair($"{prop.Name}:{kvp.Key}", kvp.Value); } } + else if (propertyType == typeof(SentryOptions.SentryExperimentalOptions)) + { + var experimental = (SentryOptions.SentryExperimentalOptions)value; + yield return new KeyValuePair($"{prop.Name}:{nameof(SentryOptions.SentryExperimentalOptions.EnableLogs)}", Convert.ToString(experimental.EnableLogs, CultureInfo.InvariantCulture)); + } + else if (propertyType.FullName == "Sentry.Extensions.Logging.SentryLoggingOptions+SentryLoggingExperimentalOptions") + { + var property = value.GetType().GetProperty("MinimumLogLevel"); + yield return new KeyValuePair($"{prop.Name}:MinimumLogLevel", Convert.ToString(property.GetValue(value), CultureInfo.InvariantCulture)); + } else { yield return new KeyValuePair(prop.Name, Convert.ToString(value, CultureInfo.InvariantCulture)); @@ -115,6 +139,14 @@ protected void AssertContainsExpectedPropertyValues(TOptions actual) { actualValue.Should().BeEquivalentTo(expectedValue); } + else if (prop.PropertyType == typeof(SentryOptions.SentryExperimentalOptions)) + { + actualValue.Should().BeEquivalentTo(expectedValue); + } + else if (prop.PropertyType.FullName == "Sentry.Extensions.Logging.SentryLoggingOptions+SentryLoggingExperimentalOptions") + { + actualValue.Should().BeEquivalentTo(expectedValue); + } else { actualValue.Should().Be(expectedValue); diff --git a/test/Sentry.Testing/InMemoryDiagnosticLogger.cs b/test/Sentry.Testing/InMemoryDiagnosticLogger.cs index 39c554345f..48077f402b 100644 --- a/test/Sentry.Testing/InMemoryDiagnosticLogger.cs +++ b/test/Sentry.Testing/InMemoryDiagnosticLogger.cs @@ -11,6 +11,16 @@ public void Log(SentryLevel logLevel, string message, Exception exception = null Entries.Enqueue(new Entry(logLevel, message, exception, args)); } + public Entry Dequeue() + { + if (Entries.TryDequeue(out var entry)) + { + return entry; + } + + throw new InvalidOperationException("Queue is empty."); + } + public record Entry( SentryLevel Level, string Message, diff --git a/test/Sentry.Testing/InMemorySentryStructuredLogger.cs b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs new file mode 100644 index 0000000000..440b83cdc7 --- /dev/null +++ b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs @@ -0,0 +1,78 @@ +#nullable enable + +namespace Sentry.Testing; + +public sealed class InMemorySentryStructuredLogger : SentryStructuredLogger +{ + public List Entries { get; } = new(); + + /// + private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) + { + Entries.Add(LogEntry.Create(level, template, parameters)); + } + + /// + protected internal override void CaptureLog(SentryLog log) + { + throw new NotSupportedException(); + } + + /// + protected internal override void Flush() + { + // no-op + } + + public sealed class LogEntry : IEquatable + { + public static LogEntry Create(SentryLogLevel level, string template, object[]? parameters) + { + return new LogEntry(level, template, parameters is null ? ImmutableArray.Empty : ImmutableCollectionsMarshal.AsImmutableArray(parameters)); + } + + private LogEntry(SentryLogLevel level, string template, ImmutableArray parameters) + { + Level = level; + Template = template; + Parameters = parameters; + } + + public SentryLogLevel Level { get; } + public string Template { get; } + public ImmutableArray Parameters { get; } + + public void AssertEqual(SentryLogLevel level, string template, params object[] parameters) + { + var expected = Create(level, template, parameters); + Assert.Equal(expected, this); + } + + public bool Equals(LogEntry? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Level == other.Level + && Template == other.Template + && Parameters.SequenceEqual(other.Parameters); + } + + public override bool Equals(object? obj) + { + return obj is LogEntry other && Equals(other); + } + + public override int GetHashCode() + { + throw new UnreachableException(); + } + } +} diff --git a/test/Sentry.Testing/JsonSerializableExtensions.cs b/test/Sentry.Testing/JsonSerializableExtensions.cs index a8e92c735d..f71c758355 100644 --- a/test/Sentry.Testing/JsonSerializableExtensions.cs +++ b/test/Sentry.Testing/JsonSerializableExtensions.cs @@ -1,13 +1,15 @@ +#nullable enable + namespace Sentry.Testing; internal static class JsonSerializableExtensions { private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - public static string ToJsonString(this ISentryJsonSerializable serializable, IDiagnosticLogger logger = null, bool indented = false) => + public static string ToJsonString(this ISentryJsonSerializable serializable, IDiagnosticLogger? logger = null, bool indented = false) => WriteToJsonString(writer => writer.WriteSerializableValue(serializable, logger), indented); - public static string ToJsonString(this object @object, IDiagnosticLogger logger = null, bool indented = false) => + public static string ToJsonString(this object @object, IDiagnosticLogger? logger = null, bool indented = false) => WriteToJsonString(writer => writer.WriteDynamicValue(@object, logger), indented); private static string WriteToJsonString(Action writeAction, bool indented) @@ -43,4 +45,34 @@ private static string WriteToJsonString(Action writeAction, bool // Standardize on \n on all platforms, for consistency in tests. return IsWindows ? result.Replace("\r\n", "\n") : result; } + + public static JsonDocument ToJsonDocument(this ISentryJsonSerializable serializable, IDiagnosticLogger? logger = null) => + WriteToJsonDocument(writer => writer.WriteSerializableValue(serializable, logger)); + + public static JsonDocument ToJsonDocument(this T @object, Action serialize, IDiagnosticLogger? logger = null) where T : class => + WriteToJsonDocument(writer => serialize.Invoke(@object, writer, logger)); + + private static JsonDocument WriteToJsonDocument(Action writeAction) + { +#if (NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) + // This implementation is better, as it uses fewer allocations + var buffer = new ArrayBufferWriter(); + + using var writer = new Utf8JsonWriter(buffer); + writeAction(writer); + writer.Flush(); + + return JsonDocument.Parse(buffer.WrittenMemory); +#else + // This implementation is compatible with older targets + using var stream = new MemoryStream(); + + using var writer = new Utf8JsonWriter(stream); + writeAction(writer); + writer.Flush(); + + stream.Seek(0, SeekOrigin.Begin); + return JsonDocument.Parse(stream); +#endif + } } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 71d6bfb677..ffedc70e00 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -210,6 +210,8 @@ namespace Sentry public interface IHub : Sentry.ISentryClient, Sentry.ISentryScopeManager { Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + Sentry.SentryStructuredLogger Logger { get; } void BindException(System.Exception exception, Sentry.ISpan span); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, System.Action configureScope); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.SentryHint? hint, System.Action configureScope); @@ -633,6 +635,43 @@ namespace Sentry [System.Runtime.Serialization.EnumMember(Value="fatal")] Fatal = 4, } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public sealed class SentryLog + { + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public Sentry.SentryLogLevel Level { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public string Message { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public System.Collections.Immutable.ImmutableArray> Parameters { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SpanId? ParentSpanId { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public string? Template { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public System.DateTimeOffset Timestamp { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public Sentry.SentryId TraceId { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void SetAttribute(string key, object value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out object? value) { } + } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public enum SentryLogLevel + { + Trace = 1, + Debug = 5, + Info = 9, + Warning = 13, + Error = 17, + Fatal = 21, + } public sealed class SentryMessage : Sentry.ISentryJsonSerializable { public SentryMessage() { } @@ -703,6 +742,8 @@ namespace Sentry public bool EnableScopeSync { get; set; } public bool EnableSpotlight { get; set; } public string? Environment { get; set; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryOptions.SentryExperimentalOptions Experimental { get; set; } public System.Collections.Generic.IList FailedRequestStatusCodes { get; set; } public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } @@ -788,6 +829,12 @@ namespace Sentry public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public Sentry.SentryOptions UseStackTraceFactory(Sentry.Extensibility.ISentryStackTraceFactory sentryStackTraceFactory) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public sealed class SentryExperimentalOptions + { + public bool EnableLogs { get; set; } + public void SetBeforeSendLog(System.Func beforeSendLog) { } + } } public sealed class SentryPackage : Sentry.ISentryJsonSerializable { @@ -879,6 +926,11 @@ namespace Sentry public static Sentry.ITransactionTracer StartTransaction(string name, string operation, Sentry.SentryTraceHeader traceHeader) { } public static Sentry.ITransactionTracer StartTransaction(string name, string operation, string? description) { } public static void UnsetTag(string key) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public static class Experimental + { + public static Sentry.SentryStructuredLogger Logger { get; } + } } public class SentrySession : Sentry.ISentrySession { @@ -958,6 +1010,26 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryStackTrace FromJson(System.Text.Json.JsonElement json) { } } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public abstract class SentryStructuredLogger : System.IDisposable + { + protected abstract void CaptureLog(Sentry.SentryLog log); + public void Dispose() { } + protected virtual void Dispose(bool disposing) { } + protected abstract void Flush(); + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogError(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogFatal(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogInfo(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogTrace(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogWarning(string template, object[]? parameters = null, System.Action? configureLog = null) { } + } public sealed class SentryThread : Sentry.ISentryJsonSerializable { public SentryThread() { } @@ -1325,6 +1397,7 @@ namespace Sentry.Extensibility public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } @@ -1342,12 +1415,15 @@ namespace Sentry.Extensibility public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } } public class DisabledHub : Sentry.IHub, Sentry.ISentryClient, Sentry.ISentryScopeManager, System.IDisposable { public static readonly Sentry.Extensibility.DisabledHub Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryStructuredLogger Logger { get; } public void BindClient(Sentry.ISentryClient client) { } public void BindException(System.Exception exception, Sentry.ISpan span) { } public Sentry.SentryId CaptureCheckIn(string monitorSlug, Sentry.CheckInStatus status, Sentry.SentryId? sentryId = default, System.TimeSpan? duration = default, Sentry.Scope? scope = null, System.Action? configureMonitorOptions = null) { } @@ -1394,6 +1470,8 @@ namespace Sentry.Extensibility public static readonly Sentry.Extensibility.HubAdapter Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryStructuredLogger Logger { get; } public void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void AddBreadcrumb(Sentry.Infrastructure.ISystemClock clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void BindClient(Sentry.ISentryClient client) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 71d6bfb677..ffedc70e00 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -210,6 +210,8 @@ namespace Sentry public interface IHub : Sentry.ISentryClient, Sentry.ISentryScopeManager { Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + Sentry.SentryStructuredLogger Logger { get; } void BindException(System.Exception exception, Sentry.ISpan span); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, System.Action configureScope); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.SentryHint? hint, System.Action configureScope); @@ -633,6 +635,43 @@ namespace Sentry [System.Runtime.Serialization.EnumMember(Value="fatal")] Fatal = 4, } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public sealed class SentryLog + { + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public Sentry.SentryLogLevel Level { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public string Message { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public System.Collections.Immutable.ImmutableArray> Parameters { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SpanId? ParentSpanId { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public string? Template { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public System.DateTimeOffset Timestamp { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + [System.Runtime.CompilerServices.RequiredMember] + public Sentry.SentryId TraceId { get; init; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void SetAttribute(string key, object value) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out object? value) { } + } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public enum SentryLogLevel + { + Trace = 1, + Debug = 5, + Info = 9, + Warning = 13, + Error = 17, + Fatal = 21, + } public sealed class SentryMessage : Sentry.ISentryJsonSerializable { public SentryMessage() { } @@ -703,6 +742,8 @@ namespace Sentry public bool EnableScopeSync { get; set; } public bool EnableSpotlight { get; set; } public string? Environment { get; set; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryOptions.SentryExperimentalOptions Experimental { get; set; } public System.Collections.Generic.IList FailedRequestStatusCodes { get; set; } public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } @@ -788,6 +829,12 @@ namespace Sentry public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public Sentry.SentryOptions UseStackTraceFactory(Sentry.Extensibility.ISentryStackTraceFactory sentryStackTraceFactory) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public sealed class SentryExperimentalOptions + { + public bool EnableLogs { get; set; } + public void SetBeforeSendLog(System.Func beforeSendLog) { } + } } public sealed class SentryPackage : Sentry.ISentryJsonSerializable { @@ -879,6 +926,11 @@ namespace Sentry public static Sentry.ITransactionTracer StartTransaction(string name, string operation, Sentry.SentryTraceHeader traceHeader) { } public static Sentry.ITransactionTracer StartTransaction(string name, string operation, string? description) { } public static void UnsetTag(string key) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public static class Experimental + { + public static Sentry.SentryStructuredLogger Logger { get; } + } } public class SentrySession : Sentry.ISentrySession { @@ -958,6 +1010,26 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryStackTrace FromJson(System.Text.Json.JsonElement json) { } } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public abstract class SentryStructuredLogger : System.IDisposable + { + protected abstract void CaptureLog(Sentry.SentryLog log); + public void Dispose() { } + protected virtual void Dispose(bool disposing) { } + protected abstract void Flush(); + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogError(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogFatal(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogInfo(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogTrace(string template, object[]? parameters = null, System.Action? configureLog = null) { } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public void LogWarning(string template, object[]? parameters = null, System.Action? configureLog = null) { } + } public sealed class SentryThread : Sentry.ISentryJsonSerializable { public SentryThread() { } @@ -1325,6 +1397,7 @@ namespace Sentry.Extensibility public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } @@ -1342,12 +1415,15 @@ namespace Sentry.Extensibility public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } } public class DisabledHub : Sentry.IHub, Sentry.ISentryClient, Sentry.ISentryScopeManager, System.IDisposable { public static readonly Sentry.Extensibility.DisabledHub Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryStructuredLogger Logger { get; } public void BindClient(Sentry.ISentryClient client) { } public void BindException(System.Exception exception, Sentry.ISpan span) { } public Sentry.SentryId CaptureCheckIn(string monitorSlug, Sentry.CheckInStatus status, Sentry.SentryId? sentryId = default, System.TimeSpan? duration = default, Sentry.Scope? scope = null, System.Action? configureMonitorOptions = null) { } @@ -1394,6 +1470,8 @@ namespace Sentry.Extensibility public static readonly Sentry.Extensibility.HubAdapter Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] + public Sentry.SentryStructuredLogger Logger { get; } public void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void AddBreadcrumb(Sentry.Infrastructure.ISystemClock clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void BindClient(Sentry.ISentryClient client) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 8b32fbc30f..fb49f7c839 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -198,6 +198,7 @@ namespace Sentry public interface IHub : Sentry.ISentryClient, Sentry.ISentryScopeManager { Sentry.SentryId LastEventId { get; } + Sentry.SentryStructuredLogger Logger { get; } void BindException(System.Exception exception, Sentry.ISpan span); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, System.Action configureScope); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.SentryHint? hint, System.Action configureScope); @@ -621,6 +622,27 @@ namespace Sentry [System.Runtime.Serialization.EnumMember(Value="fatal")] Fatal = 4, } + public sealed class SentryLog + { + public Sentry.SentryLogLevel Level { get; init; } + public string Message { get; init; } + public System.Collections.Immutable.ImmutableArray> Parameters { get; init; } + public Sentry.SpanId? ParentSpanId { get; init; } + public string? Template { get; init; } + public System.DateTimeOffset Timestamp { get; init; } + public Sentry.SentryId TraceId { get; init; } + public void SetAttribute(string key, object value) { } + public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out object? value) { } + } + public enum SentryLogLevel + { + Trace = 1, + Debug = 5, + Info = 9, + Warning = 13, + Error = 17, + Fatal = 21, + } public sealed class SentryMessage : Sentry.ISentryJsonSerializable { public SentryMessage() { } @@ -690,6 +712,7 @@ namespace Sentry public bool EnableScopeSync { get; set; } public bool EnableSpotlight { get; set; } public string? Environment { get; set; } + public Sentry.SentryOptions.SentryExperimentalOptions Experimental { get; set; } public System.Collections.Generic.IList FailedRequestStatusCodes { get; set; } public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } @@ -769,6 +792,11 @@ namespace Sentry public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public Sentry.SentryOptions UseStackTraceFactory(Sentry.Extensibility.ISentryStackTraceFactory sentryStackTraceFactory) { } + public sealed class SentryExperimentalOptions + { + public bool EnableLogs { get; set; } + public void SetBeforeSendLog(System.Func beforeSendLog) { } + } } public sealed class SentryPackage : Sentry.ISentryJsonSerializable { @@ -860,6 +888,10 @@ namespace Sentry public static Sentry.ITransactionTracer StartTransaction(string name, string operation, Sentry.SentryTraceHeader traceHeader) { } public static Sentry.ITransactionTracer StartTransaction(string name, string operation, string? description) { } public static void UnsetTag(string key) { } + public static class Experimental + { + public static Sentry.SentryStructuredLogger Logger { get; } + } } public class SentrySession : Sentry.ISentrySession { @@ -939,6 +971,19 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryStackTrace FromJson(System.Text.Json.JsonElement json) { } } + public abstract class SentryStructuredLogger : System.IDisposable + { + protected abstract void CaptureLog(Sentry.SentryLog log); + public void Dispose() { } + protected virtual void Dispose(bool disposing) { } + protected abstract void Flush(); + public void LogDebug(string template, object[]? parameters = null, System.Action? configureLog = null) { } + public void LogError(string template, object[]? parameters = null, System.Action? configureLog = null) { } + public void LogFatal(string template, object[]? parameters = null, System.Action? configureLog = null) { } + public void LogInfo(string template, object[]? parameters = null, System.Action? configureLog = null) { } + public void LogTrace(string template, object[]? parameters = null, System.Action? configureLog = null) { } + public void LogWarning(string template, object[]? parameters = null, System.Action? configureLog = null) { } + } public sealed class SentryThread : Sentry.ISentryJsonSerializable { public SentryThread() { } @@ -1306,6 +1351,7 @@ namespace Sentry.Extensibility public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogDebug(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogError(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } @@ -1323,12 +1369,14 @@ namespace Sentry.Extensibility public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, System.Exception exception, string message) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg) { } public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2) { } + public static void LogWarning(this Sentry.Extensibility.IDiagnosticLogger logger, string message, TArg arg, TArg2 arg2, TArg3 arg3) { } } public class DisabledHub : Sentry.IHub, Sentry.ISentryClient, Sentry.ISentryScopeManager, System.IDisposable { public static readonly Sentry.Extensibility.DisabledHub Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + public Sentry.SentryStructuredLogger Logger { get; } public void BindClient(Sentry.ISentryClient client) { } public void BindException(System.Exception exception, Sentry.ISpan span) { } public Sentry.SentryId CaptureCheckIn(string monitorSlug, Sentry.CheckInStatus status, Sentry.SentryId? sentryId = default, System.TimeSpan? duration = default, Sentry.Scope? scope = null, System.Action? configureMonitorOptions = null) { } @@ -1375,6 +1423,7 @@ namespace Sentry.Extensibility public static readonly Sentry.Extensibility.HubAdapter Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + public Sentry.SentryStructuredLogger Logger { get; } public void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void AddBreadcrumb(Sentry.Infrastructure.ISystemClock clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void BindClient(Sentry.ISentryClient client) { } diff --git a/test/Sentry.Tests/Extensibility/DisabledHubTests.cs b/test/Sentry.Tests/Extensibility/DisabledHubTests.cs index e56ff65370..e03f8a82a3 100644 --- a/test/Sentry.Tests/Extensibility/DisabledHubTests.cs +++ b/test/Sentry.Tests/Extensibility/DisabledHubTests.cs @@ -35,4 +35,8 @@ public void CaptureEvent_EmptyGuid() [Fact] public async Task FlushAsync_NoOp() => await DisabledHub.Instance.FlushAsync(); + + [Fact] + public void Logger_IsDisabled() + => Assert.IsType(DisabledHub.Instance.Logger); } diff --git a/test/Sentry.Tests/Extensibility/HubAdapterTests.cs b/test/Sentry.Tests/Extensibility/HubAdapterTests.cs index 824b5e08ad..0ddb6a89b2 100644 --- a/test/Sentry.Tests/Extensibility/HubAdapterTests.cs +++ b/test/Sentry.Tests/Extensibility/HubAdapterTests.cs @@ -70,6 +70,18 @@ public void LastEventId_MockInvoked() _ = Hub.Received(1).LastEventId; } + [Fact] + public void Logger_MockInvoked() + { + var logger = new InMemorySentryStructuredLogger(); + Hub.Logger.Returns(logger); + + HubAdapter.Instance.Logger.LogWarning("Message"); + + Assert.Collection(logger.Entries, + element => element.AssertEqual(SentryLogLevel.Warning, "Message")); + } + [Fact] public void EndSession_CrashedStatus_MockInvoked() { diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index 91db9f4956..9c20fbc0fe 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -1556,6 +1556,94 @@ public async Task CaptureTransaction_WithTransactionProfiler_SendsTransactionWit lines[5].Should().BeEmpty(); } + [Fact] + public void Logger_IsDisabled_DoesNotCaptureLog() + { + // Arrange + Assert.False(_fixture.Options.Experimental.EnableLogs); + var hub = _fixture.GetSut(); + + // Act + hub.Logger.LogWarning("Message"); + hub.Logger.Flush(); + + // Assert + _fixture.Client.Received(0).CaptureEnvelope( + Arg.Is(envelope => + envelope.Items.Single(item => item.Header["type"].Equals("log")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) + ) + ); + hub.Logger.Should().BeOfType(); + } + + [Fact] + public void Logger_IsEnabled_DoesCaptureLog() + { + // Arrange + _fixture.Options.Experimental.EnableLogs = true; + var hub = _fixture.GetSut(); + + // Act + hub.Logger.LogWarning("Message"); + hub.Logger.Flush(); + + // Assert + _fixture.Client.Received(1).CaptureEnvelope( + Arg.Is(envelope => + envelope.Items.Single(item => item.Header["type"].Equals("log")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) + ) + ); + hub.Logger.Should().BeOfType(); + } + + [Fact] + public void Logger_EnableAfterCreate_HasNoEffect() + { + // Arrange + Assert.False(_fixture.Options.Experimental.EnableLogs); + var hub = _fixture.GetSut(); + + // Act + _fixture.Options.Experimental.EnableLogs = true; + + // Assert + hub.Logger.Should().BeOfType(); + } + + [Fact] + public void Logger_DisableAfterCreate_HasNoEffect() + { + // Arrange + _fixture.Options.Experimental.EnableLogs = true; + var hub = _fixture.GetSut(); + + // Act + _fixture.Options.Experimental.EnableLogs = false; + + // Assert + hub.Logger.Should().BeOfType(); + } + + [Fact] + public void Logger_Dispose_DoesCaptureLog() + { + // Arrange + _fixture.Options.Experimental.EnableLogs = true; + var hub = _fixture.GetSut(); + + // Act + hub.Logger.LogWarning("Message"); + hub.Dispose(); + + // Assert + _fixture.Client.Received(1).CaptureEnvelope( + Arg.Is(envelope => + envelope.Items.Single(item => item.Header["type"].Equals("log")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) + ) + ); + hub.Logger.Should().BeOfType(); + } + [Fact] public void Dispose_IsEnabled_SetToFalse() { diff --git a/test/Sentry.Tests/Internals/DebugStackTraceTests.verify.cs b/test/Sentry.Tests/Internals/DebugStackTraceTests.verify.cs index aa4387d9af..42f7e90e02 100644 --- a/test/Sentry.Tests/Internals/DebugStackTraceTests.verify.cs +++ b/test/Sentry.Tests/Internals/DebugStackTraceTests.verify.cs @@ -240,6 +240,7 @@ public Task CreateFrame_ForNativeAOT() IP = 2, }); + Assert.NotNull(frame); return VerifyJson(frame.ToJsonString()); } #endif diff --git a/test/Sentry.Tests/Internals/StructuredLogBatchBufferTests.cs b/test/Sentry.Tests/Internals/StructuredLogBatchBufferTests.cs new file mode 100644 index 0000000000..77d8d34ebd --- /dev/null +++ b/test/Sentry.Tests/Internals/StructuredLogBatchBufferTests.cs @@ -0,0 +1,344 @@ +#nullable enable + +namespace Sentry.Tests.Internals; + +public class StructuredLogBatchBufferTests +{ + private sealed class Fixture + { + public int Capacity { get; set; } = 2; + public TimeSpan Timeout { get; set; } = System.Threading.Timeout.InfiniteTimeSpan; + public string? Name { get; set; } + + public List TimeoutExceededInvocations { get; } = []; + + public StructuredLogBatchBuffer GetSut() + { + return new StructuredLogBatchBuffer(Capacity, Timeout, OnTimeoutExceeded, Name); + } + + private void OnTimeoutExceeded(StructuredLogBatchBuffer buffer) + { + TimeoutExceededInvocations.Add(buffer); + } + } + + private readonly Fixture _fixture = new(); + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1)] + public void Ctor_CapacityIsOutOfRange_Throws(int capacity) + { + _fixture.Capacity = capacity; + + var ctor = () => _fixture.GetSut(); + + Assert.Throws("capacity", ctor); + } + + [Theory] + [InlineData(-2)] + [InlineData(0)] + public void Ctor_TimeoutIsOutOfRange_Throws(int millisecondsTimeout) + { + _fixture.Timeout = TimeSpan.FromMilliseconds(millisecondsTimeout); + + var ctor = () => _fixture.GetSut(); + + Assert.Throws("timeout", ctor); + } + + [Fact] + public void Ctor() + { + _fixture.Capacity = 9; + _fixture.Name = nameof(Ctor); + + using var buffer = _fixture.GetSut(); + + buffer.Capacity.Should().Be(_fixture.Capacity); + buffer.IsEmpty.Should().BeTrue(); + buffer.Name.Should().Be(_fixture.Name); + } + + [Fact] + public void Add_CapacityTwo_CanAddTwice() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + buffer.Capacity.Should().Be(2); + buffer.IsEmpty.Should().BeTrue(); + + buffer.Add("one").Should().Be(StructuredLogBatchBufferAddStatus.AddedFirst); + buffer.IsEmpty.Should().BeFalse(); + + buffer.Add("two").Should().Be(StructuredLogBatchBufferAddStatus.AddedLast); + buffer.IsEmpty.Should().BeFalse(); + + buffer.Add("three").Should().Be(StructuredLogBatchBufferAddStatus.IgnoredCapacityExceeded); + buffer.IsEmpty.Should().BeFalse(); + + buffer.Add("four").Should().Be(StructuredLogBatchBufferAddStatus.IgnoredCapacityExceeded); + buffer.IsEmpty.Should().BeFalse(); + } + + [Fact] + public void Add_CapacityThree_CanAddThrice() + { + _fixture.Capacity = 3; + using var buffer = _fixture.GetSut(); + + buffer.Capacity.Should().Be(3); + buffer.IsEmpty.Should().BeTrue(); + + buffer.Add("one").Should().Be(StructuredLogBatchBufferAddStatus.AddedFirst); + buffer.IsEmpty.Should().BeFalse(); + + buffer.Add("two").Should().Be(StructuredLogBatchBufferAddStatus.Added); + buffer.IsEmpty.Should().BeFalse(); + + buffer.Add("three").Should().Be(StructuredLogBatchBufferAddStatus.AddedLast); + buffer.IsEmpty.Should().BeFalse(); + + buffer.Add("four").Should().Be(StructuredLogBatchBufferAddStatus.IgnoredCapacityExceeded); + buffer.IsEmpty.Should().BeFalse(); + } + + [Fact] + public void Add_Flushing_CannotAdd() + { + _fixture.Capacity = 2; + var buffer = _fixture.GetSut(); + + var flushScope = buffer.TryEnterFlushScope(); + + buffer.Add("one").Should().Be(StructuredLogBatchBufferAddStatus.IgnoredIsFlushing); + buffer.IsEmpty.Should().BeTrue(); + + flushScope.Dispose(); + + buffer.Add("two").Should().Be(StructuredLogBatchBufferAddStatus.AddedFirst); + buffer.IsEmpty.Should().BeFalse(); + } + + [Fact] + public void Add_Disposed_CannotAdd() + { + _fixture.Capacity = 2; + var buffer = _fixture.GetSut(); + + buffer.Dispose(); + + buffer.Add("one").Should().Be(StructuredLogBatchBufferAddStatus.IgnoredIsDisposed); + buffer.IsEmpty.Should().BeTrue(); + } + + [Fact] + public void Flush_IsEmpty_EmptyArray() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + using var flushScope = buffer.TryEnterFlushScope(); + var array = flushScope.Flush(); + + array.Should().BeEmpty(); + buffer.Capacity.Should().Be(2); + buffer.IsEmpty.Should().BeTrue(); + } + + [Fact] + public void Flush_IsNotEmptyNorFull_PartialCopy() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + buffer.Add("one"); + using var flushScope = buffer.TryEnterFlushScope(); + var array = flushScope.Flush(); + + array.Messages().Should().Equal(["one"]); + buffer.Capacity.Should().Be(2); + buffer.IsEmpty.Should().BeTrue(); + } + + [Fact] + public void Flush_IsFull_FullCopy() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + buffer.Add("one"); + buffer.Add("two"); + using var flushScope = buffer.TryEnterFlushScope(); + var array = flushScope.Flush(); + + array.Messages().Should().Equal(["one", "two"]); + buffer.Capacity.Should().Be(2); + buffer.IsEmpty.Should().BeTrue(); + } + + [Fact] + public void Flush_CapacityExceeded_FullCopy() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + buffer.Add("one"); + buffer.Add("two"); + buffer.Add("three"); + using var flushScope = buffer.TryEnterFlushScope(); + var array = flushScope.Flush(); + + array.Messages().Should().Equal(["one", "two"]); + buffer.Capacity.Should().Be(2); + buffer.IsEmpty.Should().BeTrue(); + } + + [Fact] + public void Flush_DoubleFlush_SecondArrayIsEmpty() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + buffer.Add("one"); + buffer.Add("two"); + using var flushScope = buffer.TryEnterFlushScope(); + var first = flushScope.Flush(); + var second = flushScope.Flush(); + + first.Messages().Should().Equal(["one", "two"]); + second.Should().BeEmpty(); + } + + [Fact] + public void Flush_SecondFlush_NoFlushNoClear() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + buffer.Add("one"); + buffer.Add("two"); + + using (var flushScope = buffer.TryEnterFlushScope()) + { + flushScope.IsEntered.Should().BeTrue(); + buffer.IsEmpty.Should().BeFalse(); + } + + using (var flushScope = buffer.TryEnterFlushScope()) + { + flushScope.IsEntered.Should().BeTrue(); + flushScope.Flush().Messages().Should().Equal(["one", "two"]); + buffer.IsEmpty.Should().BeTrue(); + } + } + + [Fact] + public void Flush_TryEnterFlushScopeTwice_CanOnlyEnterOnce() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + buffer.Add("one"); + buffer.Add("two"); + using var first = buffer.TryEnterFlushScope(); + using var second = buffer.TryEnterFlushScope(); + + first.IsEntered.Should().BeTrue(); + second.IsEntered.Should().BeFalse(); + + first.Flush().Messages().Should().Equal(["one", "two"]); + AssertFlushThrows(second); + } + + [Fact] + public void Flush_DisposedScope_Throws() + { + _fixture.Capacity = 2; + using var buffer = _fixture.GetSut(); + + buffer.Add("one"); + buffer.Add("two"); + var flushScope = buffer.TryEnterFlushScope(); + flushScope.Dispose(); + + AssertFlushThrows(flushScope); + } + + [Fact] + public void Flush_DisposedBuffer_CannotEnter() + { + _fixture.Capacity = 2; + var buffer = _fixture.GetSut(); + + buffer.Dispose(); + using var flushScope = buffer.TryEnterFlushScope(); + + flushScope.IsEntered.Should().BeFalse(); + AssertFlushThrows(flushScope); + } + + [Fact] + public void OnIntervalElapsed_Timeout_InvokesCallback() + { + _fixture.Timeout = Timeout.InfiniteTimeSpan; + using var buffer = _fixture.GetSut(); + + buffer.OnIntervalElapsed(null); + _fixture.TimeoutExceededInvocations.Should().HaveCount(1); + + buffer.OnIntervalElapsed(null); + _fixture.TimeoutExceededInvocations.Should().HaveCount(2); + + _fixture.TimeoutExceededInvocations[0].Should().BeSameAs(buffer); + _fixture.TimeoutExceededInvocations[1].Should().BeSameAs(buffer); + } + + [Fact] + public void OnIntervalElapsed_Disposed_DoesNotInvokeCallback() + { + _fixture.Timeout = Timeout.InfiniteTimeSpan; + var buffer = _fixture.GetSut(); + + buffer.Dispose(); + buffer.OnIntervalElapsed(null); + + _fixture.TimeoutExceededInvocations.Should().BeEmpty(); + } + + // cannot use xUnit's Throws() nor Fluent Assertions' ThrowExactly() because the FlushScope is a ref struct + private static void AssertFlushThrows(StructuredLogBatchBuffer.FlushScope flushScope) + where T : Exception + { + Exception? exception = null; + try + { + flushScope.Flush(); + } + catch (Exception e) + { + exception = e; + } + + exception.Should().NotBeNull(); + exception.Should().BeOfType(); + } +} + +file static class StructuredLogBatchBufferHelpers +{ + public static StructuredLogBatchBufferAddStatus Add(this StructuredLogBatchBuffer buffer, string item) + { + SentryLog log = new(DateTimeOffset.MinValue, SentryId.Empty, SentryLogLevel.Trace, item); + return buffer.Add(log); + } + + public static string[] Messages(this SentryLog[] logs) + { + return logs.Select(static log => log.Message).ToArray(); + } +} diff --git a/test/Sentry.Tests/Internals/StructuredLogBatchProcessorTests.cs b/test/Sentry.Tests/Internals/StructuredLogBatchProcessorTests.cs new file mode 100644 index 0000000000..12279f6115 --- /dev/null +++ b/test/Sentry.Tests/Internals/StructuredLogBatchProcessorTests.cs @@ -0,0 +1,259 @@ +#nullable enable + +namespace Sentry.Tests.Internals; + +public class StructuredLogBatchProcessorTests : IDisposable +{ + private sealed class Fixture + { + private readonly IHub _hub; + + public ClientReportRecorder ClientReportRecorder { get; } + public InMemoryDiagnosticLogger DiagnosticLogger { get; } + public BlockingCollection CapturedEnvelopes { get; } + + public int ExpectedDiagnosticLogs { get; set; } + + public Fixture() + { + var options = new SentryOptions(); + var clock = new MockClock(); + + _hub = Substitute.For(); + ClientReportRecorder = new ClientReportRecorder(options, clock); + DiagnosticLogger = new InMemoryDiagnosticLogger(); + + CapturedEnvelopes = []; + _hub.CaptureEnvelope(Arg.Do(arg => CapturedEnvelopes.Add(arg))); + _hub.IsEnabled.Returns(true); + + ExpectedDiagnosticLogs = 0; + } + + public void DisableHub() + { + _hub.IsEnabled.Returns(false); + } + + public StructuredLogBatchProcessor GetSut(int batchCount) + { + return new StructuredLogBatchProcessor(_hub, batchCount, Timeout.InfiniteTimeSpan, ClientReportRecorder, DiagnosticLogger); + } + } + + private readonly Fixture _fixture = new(); + + public void Dispose() + { + Assert.Equal(_fixture.ExpectedDiagnosticLogs, _fixture.DiagnosticLogger.Entries.Count); + } + + [Fact] + public void Enqueue_NeitherSizeNorTimeoutReached_DoesNotCaptureEnvelope() + { + using var processor = _fixture.GetSut(2); + + processor.Enqueue(CreateLog("one")); + + Assert.Empty(_fixture.CapturedEnvelopes); + AssertEnvelope(); + } + + [Fact] + public void Enqueue_SizeReached_CaptureEnvelope() + { + using var processor = _fixture.GetSut(2); + + processor.Enqueue(CreateLog("one")); + processor.Enqueue(CreateLog("two")); + + Assert.Single(_fixture.CapturedEnvelopes); + AssertEnvelope("one", "two"); + } + + [Fact] + public void Enqueue_TimeoutReached_CaptureEnvelope() + { + using var processor = _fixture.GetSut(2); + + processor.Enqueue(CreateLog("one")); + processor.OnIntervalElapsed(); + + Assert.Single(_fixture.CapturedEnvelopes); + AssertEnvelope("one"); + } + + [Fact] + public void Enqueue_BothSizeAndTimeoutReached_CaptureEnvelopeOnce() + { + using var processor = _fixture.GetSut(2); + + processor.Enqueue(CreateLog("one")); + processor.Enqueue(CreateLog("two")); + processor.OnIntervalElapsed(); + + Assert.Single(_fixture.CapturedEnvelopes); + AssertEnvelope("one", "two"); + } + + [Fact] + public void Enqueue_BothTimeoutAndSizeReached_CaptureEnvelopes() + { + using var processor = _fixture.GetSut(2); + + processor.OnIntervalElapsed(); + processor.Enqueue(CreateLog("one")); + processor.OnIntervalElapsed(); + processor.Enqueue(CreateLog("two")); + processor.Enqueue(CreateLog("three")); + + Assert.Equal(2, _fixture.CapturedEnvelopes.Count); + AssertEnvelopes(["one"], ["two", "three"]); + } + + [SkippableFact] + public async Task Enqueue_Concurrency_CaptureEnvelopes() + { + Skip.If(TestEnvironment.IsGitHubActions, "Timeout may exceed on CI"); + + const int batchCount = 5; + const int maxDegreeOfParallelism = 10; + const int logsPerTask = 1_000; + + using var processor = _fixture.GetSut(batchCount); + using var sync = new ManualResetEvent(false); + + var tasks = new Task[maxDegreeOfParallelism]; + for (var i = 0; i < tasks.Length; i++) + { + tasks[i] = Task.Factory.StartNew(static state => + { + var (sync, logsPerTask, taskIndex, processor) = ((ManualResetEvent, int, int, StructuredLogBatchProcessor))state!; + sync.WaitOne(5_000); + for (var i = 0; i < logsPerTask; i++) + { + processor.Enqueue(CreateLog($"{taskIndex}-{i}")); + } + }, (sync, logsPerTask, i, processor)); + } + + sync.Set(); + await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(5)); + processor.Flush(); + _fixture.CapturedEnvelopes.CompleteAdding(); + + var capturedLogs = _fixture.CapturedEnvelopes + .SelectMany(static envelope => envelope.Items) + .Select(static item => item.Payload) + .OfType() + .Select(static payload => payload.Source) + .OfType() + .Sum(log => log.Items.Length); + var droppedLogs = 0; + + if (_fixture.ClientReportRecorder.GenerateClientReport() is { } clientReport) + { + var discardedEvent = Assert.Single(clientReport.DiscardedEvents); + Assert.Equal(new DiscardReasonWithCategory(DiscardReason.Backpressure, DataCategory.Default), discardedEvent.Key); + + droppedLogs = discardedEvent.Value; + _fixture.ExpectedDiagnosticLogs = discardedEvent.Value; + } + + var actualInvocations = maxDegreeOfParallelism * logsPerTask; + if (actualInvocations != capturedLogs + droppedLogs) + { + Assert.Fail($""" + Expected {actualInvocations} combined logs, + but actually received a total of {capturedLogs + droppedLogs} logs, + with {capturedLogs} captured logs and {droppedLogs} dropped logs, + which is a difference of {actualInvocations - capturedLogs - droppedLogs} logs. + """); + } + } + + [Fact] + public void Enqueue_HubDisabled_DoesNotCaptureEnvelope() + { + var processor = _fixture.GetSut(2); + + processor.Enqueue(CreateLog("one")); + _fixture.DisableHub(); + processor.Enqueue(CreateLog("two")); + Assert.Empty(_fixture.CapturedEnvelopes); + + processor.Flush(); + Assert.Single(_fixture.CapturedEnvelopes); + AssertEnvelope("one"); + } + + [Fact] + public void Flush_NeitherSizeNorTimeoutReached_CaptureEnvelope() + { + using var processor = _fixture.GetSut(2); + + processor.Enqueue(CreateLog("one")); + processor.Flush(); + + Assert.Single(_fixture.CapturedEnvelopes); + AssertEnvelope("one"); + } + + [Fact] + public void Dispose_Enqueue_DoesNotCaptureEnvelope() + { + var processor = _fixture.GetSut(2); + + processor.Enqueue(CreateLog("one")); + processor.Dispose(); + processor.Enqueue(CreateLog("two")); + processor.Flush(); + + Assert.Empty(_fixture.CapturedEnvelopes); + AssertEnvelope(); + _fixture.ExpectedDiagnosticLogs = 1; + } + + private static SentryLog CreateLog(string message) + { + return new SentryLog(DateTimeOffset.MinValue, SentryId.Empty, SentryLogLevel.Trace, message); + } + + private void AssertEnvelope(params string[] expected) + { + if (expected.Length == 0) + { + Assert.Empty(_fixture.CapturedEnvelopes); + return; + } + + var envelope = Assert.Single(_fixture.CapturedEnvelopes); + AssertEnvelope(envelope, expected); + } + + private void AssertEnvelopes(params string[][] expected) + { + if (expected.Length == 0) + { + Assert.Empty(_fixture.CapturedEnvelopes); + return; + } + + Assert.Equal(expected.Length, _fixture.CapturedEnvelopes.Count); + var index = 0; + foreach (var capturedEnvelope in _fixture.CapturedEnvelopes) + { + AssertEnvelope(capturedEnvelope, expected[index]); + index++; + } + } + + private static void AssertEnvelope(Envelope envelope, string[] expected) + { + var item = Assert.Single(envelope.Items); + var payload = Assert.IsType(item.Payload); + var log = payload.Source as StructuredLog; + Assert.NotNull(log); + Assert.Equal(expected, log.Items.ToArray().Select(static item => item.Message)); + } +} diff --git a/test/Sentry.Tests/Polyfilling/ImmutableCollectionsPolyfillTests.cs b/test/Sentry.Tests/Polyfilling/ImmutableCollectionsPolyfillTests.cs new file mode 100644 index 0000000000..a47b65db1e --- /dev/null +++ b/test/Sentry.Tests/Polyfilling/ImmutableCollectionsPolyfillTests.cs @@ -0,0 +1,39 @@ +namespace Sentry.Tests.Polyfilling; + +public class ImmutableCollectionsPolyfillTests +{ + [Fact] + public void ImmutableArrayBuilder_DrainToImmutable_CountIsNotCapacity() + { + var builder = ImmutableArray.CreateBuilder(2); + builder.Add("one"); + + builder.Count.Should().Be(1); + builder.Capacity.Should().Be(2); + + var array = builder.DrainToImmutable(); + array.Length.Should().Be(1); + array.Should().BeEquivalentTo(["one"]); + + builder.Count.Should().Be(0); + builder.Capacity.Should().Be(0); + } + + [Fact] + public void ImmutableArrayBuilder_DrainToImmutable_CountIsCapacity() + { + var builder = ImmutableArray.CreateBuilder(2); + builder.Add("one"); + builder.Add("two"); + + builder.Count.Should().Be(2); + builder.Capacity.Should().Be(2); + + var array = builder.DrainToImmutable(); + array.Length.Should().Be(2); + array.Should().BeEquivalentTo(["one", "two"]); + + builder.Count.Should().Be(0); + builder.Capacity.Should().Be(0); + } +} diff --git a/test/Sentry.Tests/Protocol/StructuredLogTests.cs b/test/Sentry.Tests/Protocol/StructuredLogTests.cs new file mode 100644 index 0000000000..3c491900e3 --- /dev/null +++ b/test/Sentry.Tests/Protocol/StructuredLogTests.cs @@ -0,0 +1,58 @@ +namespace Sentry.Tests.Protocol; + +/// +/// See . +/// See also . +/// +public class StructuredLogTests +{ + private readonly TestOutputDiagnosticLogger _output; + + public StructuredLogTests(ITestOutputHelper output) + { + _output = new TestOutputDiagnosticLogger(output); + } + + [Fact] + public void Type_IsAssignableFrom_ISentryJsonSerializable() + { + var log = new StructuredLog([]); + + Assert.IsAssignableFrom(log); + } + + [Fact] + public void Length_One_Single() + { + var log = new StructuredLog([CreateLog()]); + + var length = log.Length; + + Assert.Equal(1, length); + } + + [Fact] + public void Items_One_Single() + { + var log = new StructuredLog([CreateLog()]); + + var items = log.Items; + + Assert.Equal(1, items.Length); + } + + [Fact] + public void WriteTo_Empty_AsJson() + { + var log = new StructuredLog([]); + + var document = log.ToJsonDocument(_output); + + Assert.Equal("""{"items":[]}""", document.RootElement.ToString()); + } + + private static SentryLog CreateLog() + { + return new SentryLog(DateTimeOffset.MinValue, SentryId.Empty, SentryLogLevel.Trace, "message"); + } +} diff --git a/test/Sentry.Tests/SentryLogLevelTests.cs b/test/Sentry.Tests/SentryLogLevelTests.cs new file mode 100644 index 0000000000..36b557ea08 --- /dev/null +++ b/test/Sentry.Tests/SentryLogLevelTests.cs @@ -0,0 +1,152 @@ +namespace Sentry.Tests; + +/// +/// +/// +public class SentryLogLevelTests +{ + private readonly InMemoryDiagnosticLogger _logger; + + public SentryLogLevelTests() + { + _logger = new InMemoryDiagnosticLogger(); + } + +#if NET7_0_OR_GREATER + [Fact] + public void Enum_GetValuesAsUnderlyingType_LowestSeverityNumberPerSeverityRange() + { + var values = Enum.GetValuesAsUnderlyingType(); + + Assert.Collection(values.OfType(), + element => Assert.Equal(1, element), + element => Assert.Equal(5, element), + element => Assert.Equal(9, element), + element => Assert.Equal(13, element), + element => Assert.Equal(17, element), + element => Assert.Equal(21, element)); + } +#endif + + [Theory] + [MemberData(nameof(SeverityTextAndSeverityNumber))] + public void SeverityTextAndSeverityNumber_WithinRange_MatchesProtocol(int level, string text, int? number) + { + var @enum = (SentryLogLevel)level; + + var (severityText, severityNumber) = @enum.ToSeverityTextAndOptionalSeverityNumber(_logger); + + Assert.Multiple( + () => Assert.Equal(text, severityText), + () => Assert.Equal(number, severityNumber)); + Assert.Empty(_logger.Entries); + } + + [Theory] + [InlineData(0, "trace", 1, "minimum")] + [InlineData(25, "fatal", 24, "maximum")] + public void SeverityTextAndSeverityNumber_OutOfRange_ClampValue(int level, string text, int? number, string clamp) + { + var @enum = (SentryLogLevel)level; + + var (severityText, severityNumber) = @enum.ToSeverityTextAndOptionalSeverityNumber(_logger); + + Assert.Multiple( + () => Assert.Equal(text, severityText), + () => Assert.Equal(number, severityNumber)); + var entry = Assert.Single(_logger.Entries); + Assert.Multiple( + () => Assert.Equal(SentryLevel.Debug, entry.Level), + () => Assert.Equal($$"""Log level {0} out of range ... clamping to {{clamp}} value {1} ({2})""", entry.Message), + () => Assert.Null(entry.Exception), + () => Assert.Equal([@enum, number, text], entry.Args)); + } + + public static TheoryData SeverityTextAndSeverityNumber() + { + return new TheoryData + { + { 1, "trace", null }, + { 2, "trace", 2 }, + { 3, "trace", 3 }, + { 4, "trace", 4 }, + { 5, "debug", null }, + { 6, "debug", 6 }, + { 7, "debug", 7 }, + { 8, "debug", 8 }, + { 9, "info", null }, + { 10, "info", 10 }, + { 11, "info", 11 }, + { 12, "info", 12 }, + { 13, "warn", null }, + { 14, "warn", 14 }, + { 15, "warn", 15 }, + { 16, "warn", 16 }, + { 17, "error", null }, + { 18, "error", 18 }, + { 19, "error", 19 }, + { 20, "error", 20 }, + { 21, "fatal", null }, + { 22, "fatal", 22 }, + { 23, "fatal", 23 }, + { 24, "fatal", 24 }, + }; + } + + [Theory] + [MemberData(nameof(Create))] + public void Create_WithinRange_UsesLowestSeverityNumberOfRange(int value, SentryLogLevel level) + { + var @enum = SentryLogLevelExtensions.FromValue(value, _logger); + + Assert.Equal(level, @enum); + Assert.Empty(_logger.Entries); + } + + [Theory] + [InlineData(0, SentryLogLevel.Trace, "minimum")] + [InlineData(25, SentryLogLevel.Fatal, "maximum")] + public void Create_OutOfRange_ClampValue(int value, SentryLogLevel level, string clamp) + { + var @enum = SentryLogLevelExtensions.FromValue(value, _logger); + + Assert.Equal(level, @enum); + var entry = Assert.Single(_logger.Entries); + Assert.Multiple( + () => Assert.Equal(SentryLevel.Debug, entry.Level), + () => Assert.Equal($$"""Log number {0} out of range ... clamping to {{clamp}} level {1}""", entry.Message), + () => Assert.Null(entry.Exception), + () => Assert.Equal([value, level], entry.Args)); + } + + public static TheoryData Create() + { + return new TheoryData + { + { 1, SentryLogLevel.Trace }, + { 2, SentryLogLevel.Trace }, + { 3, SentryLogLevel.Trace }, + { 4, SentryLogLevel.Trace }, + { 5, SentryLogLevel.Debug }, + { 6, SentryLogLevel.Debug }, + { 7, SentryLogLevel.Debug }, + { 8, SentryLogLevel.Debug }, + { 9, SentryLogLevel.Info }, + { 10, SentryLogLevel.Info }, + { 11, SentryLogLevel.Info }, + { 12, SentryLogLevel.Info }, + { 13, SentryLogLevel.Warning }, + { 14, SentryLogLevel.Warning }, + { 15, SentryLogLevel.Warning }, + { 16, SentryLogLevel.Warning }, + { 17, SentryLogLevel.Error }, + { 18, SentryLogLevel.Error }, + { 19, SentryLogLevel.Error }, + { 20, SentryLogLevel.Error }, + { 21, SentryLogLevel.Fatal }, + { 22, SentryLogLevel.Fatal }, + { 23, SentryLogLevel.Fatal }, + { 24, SentryLogLevel.Fatal }, + }; + } +} diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs new file mode 100644 index 0000000000..3393137b85 --- /dev/null +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -0,0 +1,477 @@ +using System.Text.Encodings.Web; +using Sentry.PlatformAbstractions; + +namespace Sentry.Tests; + +/// +/// See . +/// See also . +/// +public class SentryLogTests +{ + private static readonly DateTimeOffset Timestamp = new(2025, 04, 22, 14, 51, 00, 789, TimeSpan.FromHours(2)); + private static readonly SentryId TraceId = SentryId.Create(); + private static readonly SpanId? ParentSpanId = SpanId.Create(); + + private static readonly ISystemClock Clock = new MockClock(Timestamp); + + private readonly TestOutputDiagnosticLogger _output; + + public SentryLogTests(ITestOutputHelper output) + { + _output = new TestOutputDiagnosticLogger(output); + } + + [Fact] + public void Protocol_Default_VerifyAttributes() + { + var options = new SentryOptions + { + Environment = "my-environment", + Release = "my-release", + }; + var sdk = new SdkVersion + { + Name = "Sentry.Test.SDK", + Version = "1.2.3-test+Sentry" + }; + + var log = new SentryLog(Timestamp, TraceId, (SentryLogLevel)24, "message") + { + Template = "template", + Parameters = ImmutableArray.Create(new KeyValuePair("param", "params")), + ParentSpanId = ParentSpanId, + }; + log.SetAttribute("attribute", "value"); + log.SetDefaultAttributes(options, sdk); + + log.Timestamp.Should().Be(Timestamp); + log.TraceId.Should().Be(TraceId); + log.Level.Should().Be((SentryLogLevel)24); + log.Message.Should().Be("message"); + log.Template.Should().Be("template"); + log.Parameters.Should().BeEquivalentTo(new KeyValuePair[] { new("param", "params"), }); + log.ParentSpanId.Should().Be(ParentSpanId); + + log.TryGetAttribute("attribute", out object attribute).Should().BeTrue(); + attribute.Should().Be("value"); + log.TryGetAttribute("sentry.environment", out string environment).Should().BeTrue(); + environment.Should().Be(options.Environment); + log.TryGetAttribute("sentry.release", out string release).Should().BeTrue(); + release.Should().Be(options.Release); + log.TryGetAttribute("sentry.sdk.name", out string name).Should().BeTrue(); + name.Should().Be(sdk.Name); + log.TryGetAttribute("sentry.sdk.version", out string version).Should().BeTrue(); + version.Should().Be(sdk.Version); + log.TryGetAttribute("not-found", out object notFound).Should().BeFalse(); + notFound.Should().BeNull(); + } + + [Fact] + public void WriteTo_Envelope_MinimalSerializedSentryLog() + { + var options = new SentryOptions + { + Environment = "my-environment", + Release = "my-release", + }; + + var log = new SentryLog(Timestamp, TraceId, SentryLogLevel.Trace, "message"); + log.SetDefaultAttributes(options, new SdkVersion()); + + var envelope = Envelope.FromLog(new StructuredLog([log])); + + using var stream = new MemoryStream(); + envelope.Serialize(stream, _output, Clock); + stream.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(stream); + var header = JsonDocument.Parse(reader.ReadLine()!); + var item = JsonDocument.Parse(reader.ReadLine()!); + var payload = JsonDocument.Parse(reader.ReadLine()!); + + reader.EndOfStream.Should().BeTrue(); + + header.ToIndentedJsonString().Should().Be($$""" + { + "sdk": { + "name": "{{SdkVersion.Instance.Name}}", + "version": "{{SdkVersion.Instance.Version}}" + }, + "sent_at": "{{Timestamp.Format()}}" + } + """); + + item.ToIndentedJsonString().Should().Match(""" + { + "type": "log", + "item_count": 1, + "content_type": "application/vnd.sentry.items.log+json", + "length": ?* + } + """); + + payload.ToIndentedJsonString().Should().Be($$""" + { + "items": [ + { + "timestamp": {{Timestamp.GetTimestamp()}}, + "level": "trace", + "body": "message", + "trace_id": "{{TraceId.ToString()}}", + "attributes": { + "sentry.environment": { + "value": "my-environment", + "type": "string" + }, + "sentry.release": { + "value": "my-release", + "type": "string" + } + } + } + ] + } + """); + + _output.Entries.Should().BeEmpty(); + } + + [Fact] + public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() + { + var options = new SentryOptions + { + Environment = "my-environment", + Release = "my-release", + }; + + var log = new SentryLog(Timestamp, TraceId, (SentryLogLevel)24, "message") + { + Template = "template", + Parameters = ImmutableArray.Create(new KeyValuePair("0", "string"), new KeyValuePair("1", false), new KeyValuePair("2", 1), new KeyValuePair("3", 2.2)), + ParentSpanId = ParentSpanId, + }; + log.SetAttribute("string-attribute", "string-value"); + log.SetAttribute("boolean-attribute", true); + log.SetAttribute("integer-attribute", 3); + log.SetAttribute("double-attribute", 4.4); + log.SetDefaultAttributes(options, new SdkVersion { Name = "Sentry.Test.SDK", Version = "1.2.3-test+Sentry" }); + + var envelope = EnvelopeItem.FromLog(new StructuredLog([log])); + + using var stream = new MemoryStream(); + envelope.Serialize(stream, _output); + stream.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(stream); + var item = JsonDocument.Parse(reader.ReadLine()!); + var payload = JsonDocument.Parse(reader.ReadLine()!); + + reader.EndOfStream.Should().BeTrue(); + + item.ToIndentedJsonString().Should().Match(""" + { + "type": "log", + "item_count": 1, + "content_type": "application/vnd.sentry.items.log+json", + "length": ?* + } + """); + + payload.ToIndentedJsonString().Should().Be($$""" + { + "items": [ + { + "timestamp": {{Timestamp.GetTimestamp()}}, + "level": "fatal", + "body": "message", + "trace_id": "{{TraceId.ToString()}}", + "severity_number": 24, + "attributes": { + "sentry.message.template": { + "value": "template", + "type": "string" + }, + "sentry.message.parameter.0": { + "value": "string", + "type": "string" + }, + "sentry.message.parameter.1": { + "value": false, + "type": "boolean" + }, + "sentry.message.parameter.2": { + "value": 1, + "type": "integer" + }, + "sentry.message.parameter.3": { + "value": {{2.2.Format()}}, + "type": "double" + }, + "string-attribute": { + "value": "string-value", + "type": "string" + }, + "boolean-attribute": { + "value": true, + "type": "boolean" + }, + "integer-attribute": { + "value": 3, + "type": "integer" + }, + "double-attribute": { + "value": {{4.4.Format()}}, + "type": "double" + }, + "sentry.environment": { + "value": "my-environment", + "type": "string" + }, + "sentry.release": { + "value": "my-release", + "type": "string" + }, + "sentry.sdk.name": { + "value": "Sentry.Test.SDK", + "type": "string" + }, + "sentry.sdk.version": { + "value": "1.2.3-test+Sentry", + "type": "string" + }, + "sentry.trace.parent_span_id": { + "value": "{{ParentSpanId.ToString()}}", + "type": "string" + } + } + } + ] + } + """); + + _output.Entries.Should().BeEmpty(); + } + + [Fact] + public void WriteTo_MessageParameters_AsAttributes() + { + var log = new SentryLog(Timestamp, TraceId, SentryLogLevel.Trace, "message") + { + Parameters = + [ + new KeyValuePair("00", sbyte.MinValue), + new KeyValuePair("01", byte.MaxValue), + new KeyValuePair("02", short.MinValue), + new KeyValuePair("03", ushort.MaxValue), + new KeyValuePair("04", int.MinValue), + new KeyValuePair("05", uint.MaxValue), + new KeyValuePair("06", long.MinValue), + new KeyValuePair("07", ulong.MaxValue), +#if NET5_0_OR_GREATER + new KeyValuePair("08", nint.MinValue), + new KeyValuePair("09", nuint.MaxValue), +#endif + new KeyValuePair("10", 1f), + new KeyValuePair("11", 2d), + new KeyValuePair("12", 3m), + new KeyValuePair("13", true), + new KeyValuePair("14", 'c'), + new KeyValuePair("15", "string"), +#if (NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) + new KeyValuePair("16", KeyValuePair.Create("key", "value")), +#else + new KeyValuePair("16", new KeyValuePair("key", "value")), +#endif + new KeyValuePair("17", null), + ], + }; + + var document = log.ToJsonDocument(static (obj, writer, logger) => obj.WriteTo(writer, logger), _output); + var attributes = document.RootElement.GetProperty("attributes"); + Assert.Collection(attributes.EnumerateObject().ToArray(), + property => property.AssertAttributeInteger("sentry.message.parameter.00", json => json.GetSByte(), sbyte.MinValue), + property => property.AssertAttributeInteger("sentry.message.parameter.01", json => json.GetByte(), byte.MaxValue), + property => property.AssertAttributeInteger("sentry.message.parameter.02", json => json.GetInt16(), short.MinValue), + property => property.AssertAttributeInteger("sentry.message.parameter.03", json => json.GetUInt16(), ushort.MaxValue), + property => property.AssertAttributeInteger("sentry.message.parameter.04", json => json.GetInt32(), int.MinValue), + property => property.AssertAttributeInteger("sentry.message.parameter.05", json => json.GetUInt32(), uint.MaxValue), + property => property.AssertAttributeInteger("sentry.message.parameter.06", json => json.GetInt64(), long.MinValue), + property => property.AssertAttributeString("sentry.message.parameter.07", json => json.GetString(), ulong.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), +#if NET5_0_OR_GREATER + property => property.AssertAttributeInteger("sentry.message.parameter.08", json => json.GetInt64(), nint.MinValue), + property => property.AssertAttributeString("sentry.message.parameter.09", json => json.GetString(), nuint.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), +#endif + property => property.AssertAttributeDouble("sentry.message.parameter.10", json => json.GetSingle(), 1f), + property => property.AssertAttributeDouble("sentry.message.parameter.11", json => json.GetDouble(), 2d), + property => property.AssertAttributeString("sentry.message.parameter.12", json => json.GetString(), 3m.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeBoolean("sentry.message.parameter.13", json => json.GetBoolean(), true), + property => property.AssertAttributeString("sentry.message.parameter.14", json => json.GetString(), "c"), + property => property.AssertAttributeString("sentry.message.parameter.15", json => json.GetString(), "string"), + property => property.AssertAttributeString("sentry.message.parameter.16", json => json.GetString(), "[key, value]") + ); + Assert.Collection(_output.Entries, + entry => entry.Message.Should().Match("*ulong*is not supported*overflow*"), +#if NET5_0_OR_GREATER + entry => entry.Message.Should().Match("*nuint*is not supported*64-bit*"), +#endif + entry => entry.Message.Should().Match("*decimal*is not supported*overflow*"), + entry => entry.Message.Should().Match("*System.Collections.Generic.KeyValuePair`2[System.String,System.String]*is not supported*ToString*"), + entry => entry.Message.Should().Match("*null*is not supported*ignored*") + ); + } + + [Fact] + public void WriteTo_Attributes_AsJson() + { + var log = new SentryLog(Timestamp, TraceId, SentryLogLevel.Trace, "message"); + log.SetAttribute("sbyte", sbyte.MinValue); + log.SetAttribute("byte", byte.MaxValue); + log.SetAttribute("short", short.MinValue); + log.SetAttribute("ushort", ushort.MaxValue); + log.SetAttribute("int", int.MinValue); + log.SetAttribute("uint", uint.MaxValue); + log.SetAttribute("long", long.MinValue); + log.SetAttribute("ulong", ulong.MaxValue); +#if NET5_0_OR_GREATER + log.SetAttribute("nint", nint.MinValue); + log.SetAttribute("nuint", nuint.MaxValue); +#endif + log.SetAttribute("float", 1f); + log.SetAttribute("double", 2d); + log.SetAttribute("decimal", 3m); + log.SetAttribute("bool", true); + log.SetAttribute("char", 'c'); + log.SetAttribute("string", "string"); +#if (NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) + log.SetAttribute("object", KeyValuePair.Create("key", "value")); +#else + log.SetAttribute("object", new KeyValuePair("key", "value")); +#endif + log.SetAttribute("null", null!); + + var document = log.ToJsonDocument(static (obj, writer, logger) => obj.WriteTo(writer, logger), _output); + var attributes = document.RootElement.GetProperty("attributes"); + Assert.Collection(attributes.EnumerateObject().ToArray(), + property => property.AssertAttributeInteger("sbyte", json => json.GetSByte(), sbyte.MinValue), + property => property.AssertAttributeInteger("byte", json => json.GetByte(), byte.MaxValue), + property => property.AssertAttributeInteger("short", json => json.GetInt16(), short.MinValue), + property => property.AssertAttributeInteger("ushort", json => json.GetUInt16(), ushort.MaxValue), + property => property.AssertAttributeInteger("int", json => json.GetInt32(), int.MinValue), + property => property.AssertAttributeInteger("uint", json => json.GetUInt32(), uint.MaxValue), + property => property.AssertAttributeInteger("long", json => json.GetInt64(), long.MinValue), + property => property.AssertAttributeString("ulong", json => json.GetString(), ulong.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), +#if NET5_0_OR_GREATER + property => property.AssertAttributeInteger("nint", json => json.GetInt64(), nint.MinValue), + property => property.AssertAttributeString("nuint", json => json.GetString(), nuint.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), +#endif + property => property.AssertAttributeDouble("float", json => json.GetSingle(), 1f), + property => property.AssertAttributeDouble("double", json => json.GetDouble(), 2d), + property => property.AssertAttributeString("decimal", json => json.GetString(), 3m.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeBoolean("bool", json => json.GetBoolean(), true), + property => property.AssertAttributeString("char", json => json.GetString(), "c"), + property => property.AssertAttributeString("string", json => json.GetString(), "string"), + property => property.AssertAttributeString("object", json => json.GetString(), "[key, value]") + ); + Assert.Collection(_output.Entries, + entry => entry.Message.Should().Match("*ulong*is not supported*overflow*"), +#if NET5_0_OR_GREATER + entry => entry.Message.Should().Match("*nuint*is not supported*64-bit*"), +#endif + entry => entry.Message.Should().Match("*decimal*is not supported*overflow*"), + entry => entry.Message.Should().Match("*System.Collections.Generic.KeyValuePair`2[System.String,System.String]*is not supported*ToString*"), + entry => entry.Message.Should().Match("*null*is not supported*ignored*") + ); + } +} + +file static class AssertExtensions +{ + public static void AssertAttributeString(this JsonProperty attribute, string name, Func getValue, T value) + { + attribute.AssertAttribute(name, "string", getValue, value); + } + + public static void AssertAttributeBoolean(this JsonProperty attribute, string name, Func getValue, T value) + { + attribute.AssertAttribute(name, "boolean", getValue, value); + } + + public static void AssertAttributeInteger(this JsonProperty attribute, string name, Func getValue, T value) + { + attribute.AssertAttribute(name, "integer", getValue, value); + } + + public static void AssertAttributeDouble(this JsonProperty attribute, string name, Func getValue, T value) + { + attribute.AssertAttribute(name, "double", getValue, value); + } + + private static void AssertAttribute(this JsonProperty attribute, string name, string type, Func getValue, T value) + { + Assert.Equal(name, attribute.Name); + Assert.Collection(attribute.Value.EnumerateObject().ToArray(), + property => + { + Assert.Equal("value", property.Name); + Assert.Equal(value, getValue(property.Value)); + }, property => + { + Assert.Equal("type", property.Name); + Assert.Equal(type, property.Value.GetString()); + }); + } +} + +file static class DateTimeOffsetExtensions +{ + public static string GetTimestamp(this DateTimeOffset value) + { + var timestamp = value.ToUnixTimeMilliseconds() / 1_000.0; + return timestamp.ToString(NumberFormatInfo.InvariantInfo); + } +} + +file static class JsonFormatterExtensions +{ + public static string Format(this DateTimeOffset value) + { + return value.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz", DateTimeFormatInfo.InvariantInfo); + } + + public static string Format(this double value) + { + if (SentryRuntime.Current.IsNetFx() || SentryRuntime.Current.IsMono()) + { + // since .NET Core 3.0, the Floating-Point Formatter returns the shortest roundtrippable string, rather than the exact string + // e.g. on .NET Framework (Windows) + // * 2.2.ToString() -> 2.2000000000000002 + // * 4.4.ToString() -> 4.4000000000000004 + // see https://devblogs.microsoft.com/dotnet/floating-point-parsing-and-formatting-improvements-in-net-core-3-0/ + + var utf16Text = value.ToString("G17", NumberFormatInfo.InvariantInfo); + var utf8Bytes = Encoding.UTF8.GetBytes(utf16Text); + return Encoding.UTF8.GetString(utf8Bytes); + } + + return value.ToString(NumberFormatInfo.InvariantInfo); + } +} + +file static class JsonDocumentExtensions +{ + private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + private static readonly JsonSerializerOptions Options = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + WriteIndented = true, + }; + + public static string ToIndentedJsonString(this JsonDocument document) + { + var json = JsonSerializer.Serialize(document, Options); + + // Standardize on \n on all platforms, for consistency in tests. + return IsWindows ? json.Replace("\r\n", "\n") : json; + } +} diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs new file mode 100644 index 0000000000..aeb121badc --- /dev/null +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -0,0 +1,350 @@ +#nullable enable + +namespace Sentry.Tests; + +/// +/// +/// +public class SentryStructuredLoggerTests : IDisposable +{ + internal sealed class Fixture + { + public Fixture() + { + DiagnosticLogger = new InMemoryDiagnosticLogger(); + Hub = Substitute.For(); + Options = new SentryOptions + { + Debug = true, + DiagnosticLogger = DiagnosticLogger, + }; + Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, 789, TimeSpan.FromHours(2))); + BatchSize = 2; + BatchTimeout = Timeout.InfiniteTimeSpan; + TraceId = SentryId.Create(); + ParentSpanId = SpanId.Create(); + + Hub.IsEnabled.Returns(true); + + var traceHeader = new SentryTraceHeader(TraceId, ParentSpanId.Value, null); + Hub.GetTraceHeader().Returns(traceHeader); + } + + public InMemoryDiagnosticLogger DiagnosticLogger { get; } + public IHub Hub { get; } + public SentryOptions Options { get; } + public ISystemClock Clock { get; } + public int BatchSize { get; set; } + public TimeSpan BatchTimeout { get; set; } + public SentryId TraceId { get; private set; } + public SpanId? ParentSpanId { get; private set; } + + public void WithoutTraceHeader() + { + Hub.GetTraceHeader().Returns((SentryTraceHeader?)null); + TraceId = SentryId.Empty; + ParentSpanId = SpanId.Empty; + } + + public SentryStructuredLogger GetSut() => SentryStructuredLogger.Create(Hub, Options, Clock, BatchSize, BatchTimeout); + } + + private readonly Fixture _fixture; + + public SentryStructuredLoggerTests() + { + _fixture = new Fixture(); + } + + public void Dispose() + { + _fixture.DiagnosticLogger.Entries.Should().BeEmpty(); + } + + [Fact] + public void Create_Enabled_NewDefaultInstance() + { + _fixture.Options.Experimental.EnableLogs = true; + + var instance = _fixture.GetSut(); + var other = _fixture.GetSut(); + + instance.Should().BeOfType(); + instance.Should().NotBeSameAs(other); + } + + [Fact] + public void Create_Disabled_CachedDisabledInstance() + { + _fixture.Options.Experimental.EnableLogs.Should().BeFalse(); + + var instance = _fixture.GetSut(); + var other = _fixture.GetSut(); + + instance.Should().BeOfType(); + instance.Should().BeSameAs(other); + } + + [Theory] + [InlineData(SentryLogLevel.Trace)] + [InlineData(SentryLogLevel.Debug)] + [InlineData(SentryLogLevel.Info)] + [InlineData(SentryLogLevel.Warning)] + [InlineData(SentryLogLevel.Error)] + [InlineData(SentryLogLevel.Fatal)] + public void Log_Enabled_CapturesEnvelope(SentryLogLevel level) + { + _fixture.Options.Experimental.EnableLogs = true; + var logger = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + logger.Log(level, "Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + logger.Flush(); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + envelope.AssertEnvelope(_fixture, level); + } + + [Theory] + [InlineData(SentryLogLevel.Trace)] + [InlineData(SentryLogLevel.Debug)] + [InlineData(SentryLogLevel.Info)] + [InlineData(SentryLogLevel.Warning)] + [InlineData(SentryLogLevel.Error)] + [InlineData(SentryLogLevel.Fatal)] + public void Log_Disabled_DoesNotCaptureEnvelope(SentryLogLevel level) + { + _fixture.Options.Experimental.EnableLogs.Should().BeFalse(); + var logger = _fixture.GetSut(); + + logger.Log(level, "Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + } + + [Fact] + public void Log_WithoutTraceHeader_CapturesEnvelope() + { + _fixture.WithoutTraceHeader(); + _fixture.Options.Experimental.EnableLogs = true; + var logger = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + logger.Flush(); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + envelope.AssertEnvelope(_fixture, SentryLogLevel.Trace); + } + + [Fact] + public void Log_WithBeforeSendLog_InvokesCallback() + { + var invocations = 0; + SentryLog configuredLog = null!; + + _fixture.Options.Experimental.EnableLogs = true; + _fixture.Options.Experimental.SetBeforeSendLog((SentryLog log) => + { + invocations++; + configuredLog = log; + return log; + }); + var logger = _fixture.GetSut(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + logger.Flush(); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + invocations.Should().Be(1); + configuredLog.AssertLog(_fixture, SentryLogLevel.Trace); + } + + [Fact] + public void Log_WhenBeforeSendLogReturnsNull_DoesNotCaptureEnvelope() + { + var invocations = 0; + + _fixture.Options.Experimental.EnableLogs = true; + _fixture.Options.Experimental.SetBeforeSendLog((SentryLog log) => + { + invocations++; + return null; + }); + var logger = _fixture.GetSut(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + invocations.Should().Be(1); + } + + [Fact] + public void Log_InvalidFormat_DoesNotCaptureEnvelope() + { + _fixture.Options.Experimental.EnableLogs = true; + var logger = _fixture.GetSut(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}, {4}", ["string", true, 1, 2.2]); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Dequeue(); + entry.Level.Should().Be(SentryLevel.Error); + entry.Message.Should().Be("Template string does not match the provided argument. The Log will be dropped."); + entry.Exception.Should().BeOfType(); + entry.Args.Should().BeEmpty(); + } + + [Fact] + public void Log_InvalidConfigureLog_DoesNotCaptureEnvelope() + { + _fixture.Options.Experimental.EnableLogs = true; + var logger = _fixture.GetSut(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], static (SentryLog log) => throw new InvalidOperationException()); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Dequeue(); + entry.Level.Should().Be(SentryLevel.Error); + entry.Message.Should().Be("The configureLog callback threw an exception. The Log will be dropped."); + entry.Exception.Should().BeOfType(); + entry.Args.Should().BeEmpty(); + } + + [Fact] + public void Log_InvalidBeforeSendLog_DoesNotCaptureEnvelope() + { + _fixture.Options.Experimental.EnableLogs = true; + _fixture.Options.Experimental.SetBeforeSendLog(static (SentryLog log) => throw new InvalidOperationException()); + var logger = _fixture.GetSut(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2]); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Dequeue(); + entry.Level.Should().Be(SentryLevel.Error); + entry.Message.Should().Be("The BeforeSendLog callback threw an exception. The Log will be dropped."); + entry.Exception.Should().BeOfType(); + entry.Args.Should().BeEmpty(); + } + + [Fact] + public void Flush_AfterLog_CapturesEnvelope() + { + _fixture.Options.Experimental.EnableLogs = true; + var logger = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + logger.Flush(); + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + envelope.Should().BeNull(); + + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + envelope.Should().BeNull(); + + logger.Flush(); + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + envelope.AssertEnvelope(_fixture, SentryLogLevel.Trace); + } + + [Fact] + public void Dispose_BeforeLog_DoesNotCaptureEnvelope() + { + _fixture.Options.Experimental.EnableLogs = true; + var logger = _fixture.GetSut(); + + logger.Dispose(); + logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Dequeue(); + entry.Level.Should().Be(SentryLevel.Info); + entry.Message.Should().Be("Log Buffer full ... dropping log"); + entry.Exception.Should().BeNull(); + entry.Args.Should().BeEmpty(); + } + + private static void ConfigureLog(SentryLog log) + { + log.SetAttribute("attribute-key", "attribute-value"); + } +} + +file static class AssertionExtensions +{ + public static void AssertEnvelope(this Envelope envelope, SentryStructuredLoggerTests.Fixture fixture, SentryLogLevel level) + { + envelope.Header.Should().ContainSingle().Which.Key.Should().Be("sdk"); + var item = envelope.Items.Should().ContainSingle().Which; + + var log = item.Payload.Should().BeOfType().Which.Source.Should().BeOfType().Which; + AssertLog(log, fixture, level); + + Assert.Collection(item.Header, + element => Assert.Equal(CreateHeader("type", "log"), element), + element => Assert.Equal(CreateHeader("item_count", 1), element), + element => Assert.Equal(CreateHeader("content_type", "application/vnd.sentry.items.log+json"), element)); + } + + public static void AssertLog(this StructuredLog log, SentryStructuredLoggerTests.Fixture fixture, SentryLogLevel level) + { + var items = log.Items; + items.Length.Should().Be(1); + AssertLog(items[0], fixture, level); + } + + public static void AssertLog(this SentryLog log, SentryStructuredLoggerTests.Fixture fixture, SentryLogLevel level) + { + log.Timestamp.Should().Be(fixture.Clock.GetUtcNow()); + log.TraceId.Should().Be(fixture.TraceId); + log.Level.Should().Be(level); + log.Message.Should().Be("Template string with arguments: string, True, 1, 2.2"); + log.Template.Should().Be("Template string with arguments: {0}, {1}, {2}, {3}"); + log.Parameters.Should().BeEquivalentTo(new KeyValuePair[] { new("0", "string"), new("1", true), new("2", 1), new("3", 2.2), }); + log.ParentSpanId.Should().Be(fixture.ParentSpanId); + log.TryGetAttribute("attribute-key", out string? value).Should().BeTrue(); + value.Should().Be("attribute-value"); + } + + private static KeyValuePair CreateHeader(string name, object? value) + { + return new KeyValuePair(name, value); + } +} + +file static class SentryStructuredLoggerExtensions +{ + public static void Log(this SentryStructuredLogger logger, SentryLogLevel level, string template, object[]? parameters, Action? configureLog) + { + switch (level) + { + case SentryLogLevel.Trace: + logger.LogTrace(template, parameters, configureLog); + break; + case SentryLogLevel.Debug: + logger.LogDebug(template, parameters, configureLog); + break; + case SentryLogLevel.Info: + logger.LogInfo(template, parameters, configureLog); + break; + case SentryLogLevel.Warning: + logger.LogWarning(template, parameters, configureLog); + break; + case SentryLogLevel.Error: + logger.LogError(template, parameters, configureLog); + break; + case SentryLogLevel.Fatal: + logger.LogFatal(template, parameters, configureLog); + break; + default: + throw new ArgumentOutOfRangeException(nameof(level), level, null); + } + } +} diff --git a/test/Sentry.Tests/Threading/ScopedCountdownLockTests.cs b/test/Sentry.Tests/Threading/ScopedCountdownLockTests.cs new file mode 100644 index 0000000000..dad07e1e23 --- /dev/null +++ b/test/Sentry.Tests/Threading/ScopedCountdownLockTests.cs @@ -0,0 +1,145 @@ +using Sentry.Threading; + +namespace Sentry.Tests.Threading; + +public class ScopedCountdownLockTests : IDisposable +{ + private readonly ScopedCountdownLock _lock = new(); + + public void Dispose() + { + _lock.Dispose(); + } + + [Fact] + public void Init_IsNotEngaged_IsNotSet() + { + AssertDisengaged(false, 0); + } + + [Fact] + public void TryEnterCounterScope_IsNotEngaged_IsNotSet() + { + // increment the count + var counterOne = _lock.TryEnterCounterScope(); + counterOne.IsEntered.Should().BeTrue(); + AssertDisengaged(false, 1); + + // increment the count + var counterTwo = _lock.TryEnterCounterScope(); + counterTwo.IsEntered.Should().BeTrue(); + AssertDisengaged(false, 2); + + // decrement the count + counterOne.Dispose(); + counterOne.IsEntered.Should().BeFalse(); + AssertDisengaged(false, 1); + + // decrement the count + counterTwo.Dispose(); + counterTwo.IsEntered.Should().BeFalse(); + AssertDisengaged(false, 0); + + // no-op ... already disposed + counterOne.Dispose(); + counterTwo.Dispose(); + AssertDisengaged(false, 0); + + // increment the count + var counterThree = _lock.TryEnterCounterScope(); + counterThree.IsEntered.Should().BeTrue(); + AssertDisengaged(false, 1); + + // decrement the count + counterThree.Dispose(); + counterThree.IsEntered.Should().BeFalse(); + AssertDisengaged(false, 0); + } + + [Fact] + public void TryEnterLockScope_IsEngaged_IsSet() + { + // successfully enter a CounterScope ... increment the count + var counterOne = _lock.TryEnterCounterScope(); + counterOne.IsEntered.Should().BeTrue(); + AssertDisengaged(false, 1); + + // successfully enter a LockScope ... engages the lock + var lockOne = _lock.TryEnterLockScope(); + lockOne.IsEntered.Should().BeTrue(); + AssertEngaged(false, 1); + + // cannot enter another LockScope as long as the lock is already engaged by a LockScope + var lockTwo = _lock.TryEnterLockScope(); + lockTwo.IsEntered.Should().BeFalse(); + AssertEngaged(false, 1); + + // no-op ... LockScope is not entered + lockTwo.Wait(); + lockTwo.Dispose(); + AssertEngaged(false, 1); + + // successfully enter another CounterScope ... lock is engaged but not yet set + var counterTwo = _lock.TryEnterCounterScope(); + counterTwo.IsEntered.Should().BeTrue(); + AssertEngaged(false, 2); + + // exit a CounterScope ... decrement the count + counterTwo.Dispose(); + AssertEngaged(false, 1); + + // exit last CounterScope ... count of engaged lock reaches zero ... sets the lock + counterOne.Dispose(); + AssertEngaged(true, 0); + + // cannot enter another CounterScope as long as the engaged lock is set + var counterThree = _lock.TryEnterCounterScope(); + counterThree.IsEntered.Should().BeFalse(); + AssertEngaged(true, 0); + counterThree.Dispose(); + AssertEngaged(true, 0); + + // would block if the count of the engaged lock was not zero + lockOne.Wait(); + + // exit the LockScope ... reset the lock + lockOne.Dispose(); + AssertDisengaged(false, 0); + + // can enter a CounterScope again ... the lock not set + var counterFour = _lock.TryEnterCounterScope(); + counterFour.IsEntered.Should().BeTrue(); + AssertDisengaged(false, 1); + counterFour.Dispose(); + AssertDisengaged(false, 0); + } + + [Fact] + public void Dispose_UseAfterDispose_Throws() + { + _lock.Dispose(); + + Assert.Throws(() => _lock.TryEnterCounterScope()); + Assert.Throws(() => _lock.TryEnterLockScope()); + } + + private void AssertEngaged(bool isSet, int count) + { + using (new AssertionScope()) + { + _lock.IsSet.Should().Be(isSet); + _lock.Count.Should().Be(count); + _lock.IsEngaged.Should().BeTrue(); + } + } + + private void AssertDisengaged(bool isSet, int count) + { + using (new AssertionScope()) + { + _lock.IsSet.Should().Be(isSet); + _lock.Count.Should().Be(count); + _lock.IsEngaged.Should().BeFalse(); + } + } +}