diff --git a/.github/workflows/device-tests-ios.yml b/.github/workflows/device-tests-ios.yml index 3474a210c5..acea14ee5a 100644 --- a/.github/workflows/device-tests-ios.yml +++ b/.github/workflows/device-tests-ios.yml @@ -11,6 +11,7 @@ on: jobs: ios-tests: + if: false runs-on: macos-15 env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d666f8e5d..de37343929 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,21 @@ # Changelog -## Unreleased +## 5.12.0-alpha.0 +### Features + +- Add experimental support for [Sentry Structured Logging](https://docs.sentry.io/product/explore/logs/) via `SentrySdk.Experimental.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) +- Add experimental integrations of [Sentry Structured Logging](https://docs.sentry.io/product/explore/logs/) ([#4193](https://github.com/getsentry/sentry-dotnet/pull/4193)) + - `Sentry.AspNetCore`, enabled via `SentryAspNetCoreOptions.Experimental.EnableLogs` + - `Sentry.Extensions.Logging`, enabled via `SentryLoggingOptions.Experimental.EnableLogs` + - `Sentry.Maui`, enabled via `SentryMauiOptions.Experimental.EnableLogs` ### API changes - App Hang Tracking for iOS is now disabled by default, until this functionality is more stable. If you want to use it in your applications then you'll need to enable this manually. ([#4320](https://github.com/getsentry/sentry-dotnet/pull/4320)) ### Features +- Add experimental support for [Sentry Structured Logging](https://docs.sentry.io/product/explore/logs/) via `SentrySdk.Experimental.Logger` ([#4158](https://github.com/getsentry/sentry-dotnet/pull/4158)) - Added StartSpan and GetTransaction methods to the SentrySdk ([#4303](https://github.com/getsentry/sentry-dotnet/pull/4303)) ### Fixes diff --git a/Directory.Build.props b/Directory.Build.props index c01b616929..1994223fdc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 5.11.2 + 5.12.0-alpha.0 13 true true 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..cb77cc7c8b --- /dev/null +++ b/src/Sentry.AspNetCore/SentryAspNetCoreStructuredLoggerProvider.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Sentry.Extensions.Logging; + +namespace Sentry.AspNetCore; + +/// +/// Structured Logger Provider for Sentry. +/// +[ProviderAlias("SentryLogs")] +[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] +internal sealed class SentryAspNetCoreStructuredLoggerProvider : SentryStructuredLoggerProvider +{ + public SentryAspNetCoreStructuredLoggerProvider(IOptions options, IHub hub) + : base(options, hub) + { + } +} 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..0c589552a7 100644 --- a/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs +++ b/src/Sentry.Extensions.Logging/BindableSentryLoggingOptions.cs @@ -9,11 +9,22 @@ internal class BindableSentryLoggingOptions : BindableSentryOptions public LogLevel? MinimumEventLevel { get; set; } public bool? InitializeSdk { get; set; } + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public BindableSentryLoggingExperimentalOptions ExperimentalLogging { get; set; } = new(); + + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + 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/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/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..d49e9a6763 --- /dev/null +++ b/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.Logging; + +namespace Sentry.Extensions.Logging; + +[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] +internal sealed class SentryStructuredLogger : ILogger +{ + private readonly string _categoryName; + private readonly SentryLoggingOptions _options; + private readonly IHub _hub; + + internal SentryStructuredLogger(string categoryName, SentryLoggingOptions options, IHub hub) + { + _categoryName = categoryName; + _options = options; + _hub = hub; + } + + 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; + } + + // not quite ideal as this is a boxing allocation from Microsoft.Extensions.Logging.FormattedLogValues + /* + string? template = null; + object[]? parameters = null; + if (state is IReadOnlyList> formattedLogValues) + { + foreach (var formattedLogValue in formattedLogValues) + { + if (formattedLogValue.Key == "{OriginalFormat}" && formattedLogValue.Value is string formattedString) + { + template = formattedString; + break; + } + } + } + */ + + string message = formatter.Invoke(state, exception); + + switch (logLevel) + { + case LogLevel.Trace: + _hub.Logger.LogTrace(message); + break; + case LogLevel.Debug: + _hub.Logger.LogDebug(message); + break; + case LogLevel.Information: + _hub.Logger.LogInfo(message); + break; + case LogLevel.Warning: + _hub.Logger.LogWarning(message); + break; + case LogLevel.Error: + _hub.Logger.LogError(message); + break; + case LogLevel.Critical: + _hub.Logger.LogFatal(message); + break; + case LogLevel.None: + default: + break; + } + } +} + +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..1f28db6320 --- /dev/null +++ b/src/Sentry.Extensions.Logging/SentryStructuredLoggerProvider.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Sentry.Extensions.Logging; + +/// +/// Sentry Structured Logger Provider. +/// +[ProviderAlias("SentryLogs")] +[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] +internal class SentryStructuredLoggerProvider : ILoggerProvider +{ + private readonly IOptions _options; + private readonly IHub _hub; + + // TODO: convert this comment into an automated test + // Constructor must be public for Microsoft.Extensions.DependencyInjection + public SentryStructuredLoggerProvider(IOptions options, IHub hub) + { + _options = options; + _hub = hub; + } + + public ILogger CreateLogger(string categoryName) + { + return new SentryStructuredLogger(categoryName, _options.Value, _hub); + } + + public void Dispose() + { + } +} diff --git a/src/Sentry.Maui/Internal/SentryMauiStructuredLoggerProvider.cs b/src/Sentry.Maui/Internal/SentryMauiStructuredLoggerProvider.cs new file mode 100644 index 0000000000..97efa2e9e5 --- /dev/null +++ b/src/Sentry.Maui/Internal/SentryMauiStructuredLoggerProvider.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Sentry.Extensions.Logging; + +namespace Sentry.Maui.Internal; + +[ProviderAlias("SentryLogs")] +[Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] +internal sealed class SentryMauiStructuredLoggerProvider : SentryStructuredLoggerProvider +{ + public SentryMauiStructuredLoggerProvider(IOptions options, IHub hub) + : base(options, hub) + { + } +} diff --git a/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs b/src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs index 7d71288365..674a7c6535 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"; + }); + // Resolve the configured options and register any element event binders from these var options = new SentryMauiOptions(); configureOptions?.Invoke(options); diff --git a/src/Sentry/BindableSentryOptions.cs b/src/Sentry/BindableSentryOptions.cs index cd9e5cc8d8..acf6d1de0a 100644 --- a/src/Sentry/BindableSentryOptions.cs +++ b/src/Sentry/BindableSentryOptions.cs @@ -53,6 +53,15 @@ internal partial class BindableSentryOptions public bool? EnableSpotlight { get; set; } public string? SpotlightUrl { get; set; } + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + public BindableSentryExperimentalOptions Experimental { get; set; } = new(); + + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + internal sealed class BindableSentryExperimentalOptions + { + public bool? EnableLogs { get; set; } + } + public void ApplyTo(SentryOptions options) { options.IsGlobalModeEnabled = IsGlobalModeEnabled ?? options.IsGlobalModeEnabled; @@ -100,6 +109,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/BatchBuffer.cs b/src/Sentry/Internal/BatchBuffer.cs new file mode 100644 index 0000000000..ab40358e08 --- /dev/null +++ b/src/Sentry/Internal/BatchBuffer.cs @@ -0,0 +1,217 @@ +using Sentry.Threading; + +namespace Sentry.Internal; + +/// +/// A slim wrapper over an , intended for buffering. +/// Requires a minimum capacity of 2. +/// +/// +/// Not all members are thread-safe. +/// See individual members for notes on thread safety. +/// +[DebuggerDisplay("Name = {Name}, Capacity = {Capacity}, IsEmpty = {IsEmpty}, IsFull = {IsFull}, IsAddInProgress = {IsAddInProgress}")] +internal sealed class BatchBuffer : IDisposable +{ + private readonly T[] _array; + private int _additions; + private readonly CounterEvent _addCounter; + private readonly NonReentrantLock _addLock; + + /// + /// Create a new buffer. + /// + /// Length of the new buffer. + /// Name of the new buffer. + /// When is less than . + public BatchBuffer(int capacity, string? name = null) + { + ThrowIfLessThanTwo(capacity, nameof(capacity)); + Name = name ?? "default"; + + _array = new T[capacity]; + _additions = 0; + _addCounter = new CounterEvent(); + _addLock = new NonReentrantLock(); + } + + /// + /// Name of the buffer. + /// + /// + /// This property is thread-safe. + /// + internal string Name { get; } + + /// + /// Maximum number of elements that can be added to the buffer. + /// + /// + /// This property is thread-safe. + /// + internal int Capacity => _array.Length; + + /// + /// Have any elements been added to the buffer? + /// + /// + /// This property is not thread-safe. + /// + internal bool IsEmpty => _additions == 0; + + /// + /// Have number of elements been added to the buffer? + /// + /// + /// This property is not thread-safe. + /// + internal bool IsFull => _additions >= _array.Length; + + internal FlushScope EnterFlushScope() + { + if (_addLock.TryEnter()) + { + return new FlushScope(this); + } + + Debug.Fail("The FlushScope should not have been entered again, before the previously entered FlushScope has exited."); + return new FlushScope(); + } + + private void ExitFlushScope() + { + _addLock.Exit(); + } + + internal bool IsAddInProgress => !_addCounter.IsSet; + + internal void WaitAddCompleted() + { + _addCounter.Wait(); + } + + /// + /// Attempt to atomically add one element to the buffer. + /// + /// Element attempted to be added atomically. + /// When this method returns , is set to the Length at which the was added at. + /// when was added atomically; when was not added. + /// + /// This method is thread-safe. + /// + internal bool TryAdd(T item, out int count) + { + if (_addLock.IsEntered) + { + count = 0; + return false; + } + + using var scope = _addCounter.EnterScope(); + + count = Interlocked.Increment(ref _additions); + + if (count <= _array.Length) + { + _array[count - 1] = item; + return true; + } + + return false; + } + + /// + /// Returns a new Array consisting of the elements successfully added. + /// + /// An Array with Length of successful additions. + /// + /// This method is not thread-safe. + /// + internal T[] 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 . + /// + /// This method is not thread-safe. + /// + internal T[] ToArrayAndClear(int length) + { + Debug.Assert(_addCounter.IsSet); + var array = ToArray(length); + Clear(length); + return array; + } + + private T[] ToArray(int length) + { + if (length == 0) + { + return Array.Empty(); + } + + var array = new T[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 static void ThrowIfLessThanTwo(int capacity, string paramName) + { + if (capacity < 2) + { + ThrowLessThanTwo(capacity, paramName); + } + } + + private static void ThrowLessThanTwo(int capacity, string paramName) + { + throw new ArgumentOutOfRangeException(paramName, capacity, "Argument must be at least two."); + } + + public void Dispose() + { + _addCounter.Dispose(); + } + + internal ref struct FlushScope : IDisposable + { + private BatchBuffer? _lockObj; + + internal FlushScope(BatchBuffer lockObj) + { + _lockObj = lockObj; + } + + public void Dispose() + { + var lockObj = _lockObj; + if (lockObj is not null) + { + _lockObj = null; + lockObj.ExitFlushScope(); + } + } + } +} diff --git a/src/Sentry/Internal/BatchProcessor.cs b/src/Sentry/Internal/BatchProcessor.cs new file mode 100644 index 0000000000..f35a3b5642 --- /dev/null +++ b/src/Sentry/Internal/BatchProcessor.cs @@ -0,0 +1,154 @@ +using Sentry.Extensibility; +using Sentry.Infrastructure; +using Sentry.Protocol; +using Sentry.Protocol.Envelopes; +using Sentry.Threading; + +namespace Sentry.Internal; + +/// +/// The Sentry Batch Processor. +/// This implementation is not complete yet. +/// Also, the specification is still work in progress. +/// +/// +/// Sentry Specification: . +/// OpenTelemetry spec: . +/// +internal sealed class BatchProcessor : IDisposable +{ + private readonly IHub _hub; + private readonly TimeSpan _batchInterval; + private readonly ISystemClock _clock; + private readonly IClientReportRecorder _clientReportRecorder; + private readonly IDiagnosticLogger? _diagnosticLogger; + + private readonly Timer _timer; + private readonly object _timerCallbackLock; + private readonly BatchBuffer _buffer1; + private readonly BatchBuffer _buffer2; + private volatile BatchBuffer _activeBuffer; + private readonly NonReentrantLock _swapLock; + + private DateTimeOffset _lastFlush = DateTimeOffset.MinValue; + + public BatchProcessor(IHub hub, int batchCount, TimeSpan batchInterval, ISystemClock clock, IClientReportRecorder clientReportRecorder, IDiagnosticLogger? diagnosticLogger) + { + _hub = hub; + _batchInterval = batchInterval; + _clock = clock; + _clientReportRecorder = clientReportRecorder; + _diagnosticLogger = diagnosticLogger; + + _timer = new Timer(OnIntervalElapsed, this, Timeout.Infinite, Timeout.Infinite); + _timerCallbackLock = new object(); + + _buffer1 = new BatchBuffer(batchCount, "Buffer 1"); + _buffer2 = new BatchBuffer(batchCount, "Buffer 2"); + _activeBuffer = _buffer1; + _swapLock = new NonReentrantLock(); + } + + internal void Enqueue(SentryLog log) + { + 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"); + } + } + } + + private bool TryEnqueue(BatchBuffer buffer, SentryLog log) + { + if (buffer.TryAdd(log, out var count)) + { + if (count == 1) // is first element added to buffer after flushed + { + EnableTimer(); + } + + if (count == buffer.Capacity) // is buffer full + { + using var flushScope = buffer.EnterFlushScope(); + DisableTimer(); + + var currentActiveBuffer = _activeBuffer; + _ = TrySwapBuffer(currentActiveBuffer); + Flush(buffer, count); + } + + return true; + } + + return false; + } + + private void Flush(BatchBuffer buffer) + { + buffer.WaitAddCompleted(); + _lastFlush = _clock.GetUtcNow(); + + var logs = buffer.ToArrayAndClear(); + _ = _hub.CaptureEnvelope(Envelope.FromLog(new StructuredLog(logs))); + } + + private void Flush(BatchBuffer buffer, int count) + { + buffer.WaitAddCompleted(); + _lastFlush = _clock.GetUtcNow(); + + var logs = buffer.ToArrayAndClear(count); + _ = _hub.CaptureEnvelope(Envelope.FromLog(new StructuredLog(logs))); + } + + internal void OnIntervalElapsed(object? state) + { + lock (_timerCallbackLock) + { + var currentActiveBuffer = _activeBuffer; + + if (!currentActiveBuffer.IsEmpty && _clock.GetUtcNow() > _lastFlush) + { + _ = TrySwapBuffer(currentActiveBuffer); + Flush(currentActiveBuffer); + } + } + } + + private void EnableTimer() + { + var updated = _timer.Change(_batchInterval, Timeout.InfiniteTimeSpan); + Debug.Assert(updated, "Timer was not successfully enabled."); + } + + private void DisableTimer() + { + var updated = _timer.Change(Timeout.Infinite, Timeout.Infinite); + Debug.Assert(updated, "Timer was not successfully disabled."); + } + + private bool TrySwapBuffer(BatchBuffer currentActiveBuffer) + { + if (_swapLock.TryEnter()) + { + var newActiveBuffer = ReferenceEquals(currentActiveBuffer, _buffer1) ? _buffer2 : _buffer1; + var previousActiveBuffer = Interlocked.CompareExchange(ref _activeBuffer, newActiveBuffer, currentActiveBuffer); + + _swapLock.Exit(); + return previousActiveBuffer == currentActiveBuffer; + } + + return false; + } + + public void Dispose() + { + _timer.Dispose(); + } +} diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs new file mode 100644 index 0000000000..9dadb3108b --- /dev/null +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -0,0 +1,106 @@ +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 BatchProcessor _batchProcessor; + + internal DefaultSentryStructuredLogger(IHub hub, SentryOptions options, ISystemClock clock) + { + Debug.Assert(options is { Experimental.EnableLogs: true }); + + _hub = hub; + _options = options; + _clock = clock; + + _batchProcessor = new BatchProcessor(hub, ClampBatchCount(options.Experimental.InternalBatchSize), ClampBatchInterval(options.Experimental.InternalBatchTimeout), clock, _options.ClientReportRecorder, _options.DiagnosticLogger); + } + + private static int ClampBatchCount(int batchCount) + { + return batchCount <= 0 + ? 1 + : batchCount > 1_000_000 + ? 1_000_000 + : batchCount; + } + + private static TimeSpan ClampBatchInterval(TimeSpan batchInterval) + { + return batchInterval.TotalMilliseconds is <= 0 or > int.MaxValue + ? TimeSpan.FromMilliseconds(int.MaxValue) + : batchInterval; + } + + 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; + } + + SentryLog log = new(timestamp, traceHeader.TraceId, level, message) + { + Template = template, + Parameters = ImmutableArray.Create(parameters), + 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); + + 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 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..086f67a1bd --- /dev/null +++ b/src/Sentry/Internal/DisabledSentryStructuredLogger.cs @@ -0,0 +1,15 @@ +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 + } +} 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 67318abc54..fb13510789 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -68,6 +68,8 @@ internal Hub( PushScope(); } + Logger = SentryStructuredLogger.Create(this, options, _clock); + #if MEMORY_DUMP_SUPPORTED if (options.HeapDumpOptions is not null) { @@ -794,6 +796,8 @@ public void Dispose() _memoryMonitor?.Dispose(); #endif + Logger.Dispose(); + try { CurrentClient.FlushAsync(_options.ShutdownTimeout).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -818,4 +822,7 @@ public void Dispose() } public SentryId LastEventId => CurrentScope.LastEventId; + + [Experimental(DiagnosticId.ExperimentalFeature)] + public SentryStructuredLogger Logger { get; } } diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index b62dc82c98..2deca09790 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -445,6 +445,19 @@ internal static Envelope FromClientReport(ClientReport clientReport) return new Envelope(header, items); } + [Experimental(DiagnosticId.ExperimentalFeature)] + 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..7528a14d63 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,19 @@ internal static EnvelopeItem FromClientReport(ClientReport report) return new EnvelopeItem(header, new JsonSerializable(report)); } + [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] + 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..dab0813c2e --- /dev/null +++ b/src/Sentry/SentryLog.cs @@ -0,0 +1,245 @@ +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 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(); + + writer.WriteNumber("timestamp", Timestamp.ToUnixTimeSeconds()); + + 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) + { + for (var index = 0; index < Parameters.Length; index++) + { + SentryAttributeSerializer.WriteAttribute(writer, $"sentry.message.parameter.{index}", Parameters[index], 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..184fccc548 --- /dev/null +++ b/src/Sentry/SentryLogLevel.cs @@ -0,0 +1,135 @@ +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, +} + +[Experimental(DiagnosticId.ExperimentalFeature)] +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 ceab5113bc..8c1d9e0be0 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -1848,4 +1848,71 @@ 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; + } + + /// + /// This API will be removed in the future. + /// + /// + /// Threshold of items in the buffer when sending all items, regardless of . + /// + public int InternalBatchSize { get; set; } = 100; + + /// + /// This API will be removed in the future. + /// + /// + /// Time after which all items in the buffer are sent, regardless of . + /// Must not exceed 30 seconds. + /// + public TimeSpan InternalBatchTimeout { get; set; } = TimeSpan.FromSeconds(5); + } } diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index 15bcf2b43b..7be102174c 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -283,6 +283,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..e63566c3b8 --- /dev/null +++ b/src/Sentry/SentryStructuredLogger.cs @@ -0,0 +1,118 @@ +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) + { + return options.Experimental.EnableLogs + ? new DefaultSentryStructuredLogger(hub, options, clock) + : DisabledSentryStructuredLogger.Instance; + } + + private protected SentryStructuredLogger() + { + } + + private protected abstract void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? 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 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/CounterEvent.cs b/src/Sentry/Threading/CounterEvent.cs new file mode 100644 index 0000000000..f96cdfaeca --- /dev/null +++ b/src/Sentry/Threading/CounterEvent.cs @@ -0,0 +1,96 @@ +namespace Sentry.Threading; + +/// +/// A synchronization primitive that tracks the amount of s held. +/// +[DebuggerDisplay("Count = {Count}, IsSet = {IsSet}")] +internal sealed class CounterEvent : IDisposable +{ + private readonly ManualResetEventSlim _event; + private int _count; + + internal CounterEvent() + { + _event = new ManualResetEventSlim(true); + _count = 0; + } + + /// + /// if the event is set/signaled; otherwise, . + /// + /// When , blocks the calling thread until reaches . + public bool IsSet => _event.IsSet; + + /// + /// Gets the number of remaining s required to exit to set/signal the event. + /// + /// When , the state of the event is set/signaled, which allows the thread ing on the event to proceed. + internal int Count => _count; + + /// + /// Enter a . + /// Sets the state of the event to non-signaled, which causes ing threads to block. + /// When all s have exited, the event is set/signaled. + /// + /// A new , that must be exited via . + internal Scope EnterScope() + { + var count = Interlocked.Increment(ref _count); + Debug.Assert(count > 0); + + if (count == 1) + { + _event.Reset(); + } + + return new Scope(this); + } + + private void ExitScope() + { + var count = Interlocked.Decrement(ref _count); + Debug.Assert(count >= 0); + + if (count == 0) + { + _event.Set(); + } + } + + /// + /// Blocks the current thread until the current reaches and the event is set/signaled. + /// + /// + /// The caller of this method blocks until reaches . + /// The caller will return immediately if the event is currently in a set/signaled state. + /// + internal void Wait() + { + _event.Wait(); + } + + public void Dispose() + { + _event.Dispose(); + } + + internal ref struct Scope : IDisposable + { + private CounterEvent? _event; + + internal Scope(CounterEvent @event) + { + _event = @event; + } + + public void Dispose() + { + var @event = _event; + if (@event is not null) + { + _event = null; + @event.ExitScope(); + } + } + } +} diff --git a/src/Sentry/Threading/NonReentrantLock.cs b/src/Sentry/Threading/NonReentrantLock.cs new file mode 100644 index 0000000000..9e72247707 --- /dev/null +++ b/src/Sentry/Threading/NonReentrantLock.cs @@ -0,0 +1,27 @@ +namespace Sentry.Threading; + +[DebuggerDisplay("IsEntered = {IsEntered}")] +internal sealed class NonReentrantLock +{ + private int _state; + + internal NonReentrantLock() + { + _state = 0; + } + + internal bool IsEntered => _state == 1; + + internal bool TryEnter() + { + return Interlocked.CompareExchange(ref _state, 1, 0) == 0; + } + + internal void Exit() + { + if (Interlocked.Exchange(ref _state, 0) != 1) + { + Debug.Fail("Do not Exit the lock scope when it has not been Entered."); + } + } +} 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..8b2cd79899 --- /dev/null +++ b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerProviderTests.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Sentry.Extensions.Logging.Tests; + +public class SentryStructuredLoggerProviderTests +{ + [Fact] + public void SmokeTest() + { + var loggingOptions = new SentryLoggingOptions(); + loggingOptions.Experimental.EnableLogs = true; + IOptions options = Options.Create(loggingOptions); + IHub hub = Substitute.For(); + + var provider = new SentryStructuredLoggerProvider(options, hub); + + ILogger logger = provider.CreateLogger("categoryName"); + + logger.Should().BeOfType(); + + 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..cca67e6df2 --- /dev/null +++ b/test/Sentry.Extensions.Logging.Tests/SentryStructuredLoggerTests.cs @@ -0,0 +1,38 @@ +#nullable enable + +using Microsoft.Extensions.Logging; + +namespace Sentry.Extensions.Logging.Tests; + +public class SentryStructuredLoggerTests +{ + [Fact] + public void SmokeTest() + { + InMemorySentryStructuredLogger inMemory = new(); + + string categoryName = "CategoryName"; + SentryLoggingOptions options = new(); + options.Experimental.EnableLogs = true; + IHub hub = Substitute.For(); + hub.IsEnabled.Returns(true); + hub.Logger.Returns(inMemory); + + var logger = new SentryStructuredLogger(categoryName, options, hub); + + IDisposable? disposable = logger.BeginScope("state"); + disposable.Should().NotBeNull(); + + logger.IsEnabled(LogLevel.Warning).Should().BeTrue(); + + EventId eventId = new(1, "eventId"); + Exception exception = new InvalidOperationException(); + Func formatter = (string state, Exception? exception) => + { + state.Should().Be("state"); + exception.Should().BeOfType(); + return "Message"; + }; + logger.Log(LogLevel.Warning, eventId, "state", exception, formatter); + } +} 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/InMemorySentryStructuredLogger.cs b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs new file mode 100644 index 0000000000..c173fa7f17 --- /dev/null +++ b/test/Sentry.Testing/InMemorySentryStructuredLogger.cs @@ -0,0 +1,65 @@ +#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)); + } + + 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 3bbe328943..d16b051657 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,14 @@ 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 int InternalBatchSize { get; set; } + public System.TimeSpan InternalBatchTimeout { get; set; } + public void SetBeforeSendLog(System.Func beforeSendLog) { } + } } public sealed class SentryPackage : Sentry.ISentryJsonSerializable { @@ -879,6 +928,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 +1012,24 @@ 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 + { + public void Dispose() { } + protected virtual void Dispose(bool disposing) { } + [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() { } @@ -1326,6 +1398,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) { } @@ -1343,12 +1416,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) { } @@ -1395,6 +1471,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 3bbe328943..d16b051657 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,14 @@ 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 int InternalBatchSize { get; set; } + public System.TimeSpan InternalBatchTimeout { get; set; } + public void SetBeforeSendLog(System.Func beforeSendLog) { } + } } public sealed class SentryPackage : Sentry.ISentryJsonSerializable { @@ -879,6 +928,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 +1012,24 @@ 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 + { + public void Dispose() { } + protected virtual void Dispose(bool disposing) { } + [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() { } @@ -1326,6 +1398,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) { } @@ -1343,12 +1416,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) { } @@ -1395,6 +1471,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 8ef155217f..1664c48d92 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,13 @@ 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 int InternalBatchSize { get; set; } + public System.TimeSpan InternalBatchTimeout { get; set; } + public void SetBeforeSendLog(System.Func beforeSendLog) { } + } } public sealed class SentryPackage : Sentry.ISentryJsonSerializable { @@ -860,6 +890,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 +973,17 @@ 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 + { + public void Dispose() { } + protected virtual void Dispose(bool disposing) { } + 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() { } @@ -1307,6 +1352,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) { } @@ -1324,12 +1370,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) { } @@ -1376,6 +1424,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 85bf533df9..b03d14ca22 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -1420,6 +1420,73 @@ 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"); + + // 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(Skip = "Remove InternalBatchSize")] + public void Logger_IsEnabled_DoesCaptureLog() + { + // Arrange + _fixture.Options.Experimental.EnableLogs = true; + _fixture.Options.Experimental.InternalBatchSize = 1; + var hub = _fixture.GetSut(); + + // Act + hub.Logger.LogWarning("Message"); + + // 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 Dispose_IsEnabled_SetToFalse() { diff --git a/test/Sentry.Tests/Internals/BatchBufferTests.cs b/test/Sentry.Tests/Internals/BatchBufferTests.cs new file mode 100644 index 0000000000..fa8116ac9a --- /dev/null +++ b/test/Sentry.Tests/Internals/BatchBufferTests.cs @@ -0,0 +1,151 @@ +namespace Sentry.Tests.Internals; + +public class BatchBufferTests +{ + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1)] + public void Ctor_CapacityIsOutOfRange_Throws(int capacity) + { + var ctor = () => new BatchBuffer(capacity); + + Assert.Throws("capacity", ctor); + } + + [Fact] + public void TryAdd_CapacityTwo_CanAddTwice() + { + var buffer = new BatchBuffer(2); + AssertEmpty(buffer, 2); + + buffer.TryAdd("one", out var first).Should().BeTrue(); + Assert.Equal(1, first); + AssertPartial(buffer, 2); + + buffer.TryAdd("two", out var second).Should().BeTrue(); + Assert.Equal(2, second); + AssertFull(buffer, 2); + + buffer.TryAdd("three", out var third).Should().BeFalse(); + Assert.Equal(3, third); + AssertFull(buffer, 2); + } + + [Fact] + public void TryAdd_CapacityThree_CanAddThrice() + { + var buffer = new BatchBuffer(3); + AssertEmpty(buffer, 3); + + buffer.TryAdd("one", out var first).Should().BeTrue(); + Assert.Equal(1, first); + AssertPartial(buffer, 3); + + buffer.TryAdd("two", out var second).Should().BeTrue(); + Assert.Equal(2, second); + AssertPartial(buffer, 3); + + buffer.TryAdd("three", out var third).Should().BeTrue(); + Assert.Equal(3, third); + AssertFull(buffer, 3); + + buffer.TryAdd("four", out var fourth).Should().BeFalse(); + Assert.Equal(4, fourth); + AssertFull(buffer, 3); + } + + [Fact] + public void ToArrayAndClear_IsEmpty_EmptyArray() + { + var buffer = new BatchBuffer(2); + + var array = buffer.ToArrayAndClear(); + + Assert.Empty(array); + AssertEmpty(buffer, 2); + } + + [Fact] + public void ToArrayAndClear_IsNotEmptyNorFull_PartialCopy() + { + var buffer = new BatchBuffer(2); + buffer.TryAdd("one", out _).Should().BeTrue(); + + var array = buffer.ToArrayAndClear(); + + Assert.Collection(array, + item => Assert.Equal("one", item)); + AssertEmpty(buffer, 2); + } + + [Fact] + public void ToArrayAndClear_IsFull_FullCopy() + { + var buffer = new BatchBuffer(2); + buffer.TryAdd("one", out _).Should().BeTrue(); + buffer.TryAdd("two", out _).Should().BeTrue(); + + var array = buffer.ToArrayAndClear(); + + Assert.Collection(array, + item => Assert.Equal("one", item), + item => Assert.Equal("two", item)); + AssertEmpty(buffer, 2); + } + + [Fact] + public void ToArrayAndClear_CapacityExceeded_FullCopy() + { + var buffer = new BatchBuffer(2); + buffer.TryAdd("one", out _).Should().BeTrue(); + buffer.TryAdd("two", out _).Should().BeTrue(); + buffer.TryAdd("three", out _).Should().BeFalse(); + + var array = buffer.ToArrayAndClear(); + + Assert.Collection(array, + item => Assert.Equal("one", item), + item => Assert.Equal("two", item)); + AssertEmpty(buffer, 2); + } + + [Fact] + public void ToArrayAndClear_WithLength_PartialCopy() + { + var buffer = new BatchBuffer(2); + buffer.TryAdd("one", out _).Should().BeTrue(); + buffer.TryAdd("two", out _).Should().BeTrue(); + + var array = buffer.ToArrayAndClear(1); + + Assert.Collection(array, + item => Assert.Equal("one", item)); + AssertEmpty(buffer, 2); + } + + private static void AssertEmpty(BatchBuffer buffer, int capacity) + { + AssertProperties(buffer, capacity, true, false); + } + + private static void AssertPartial(BatchBuffer buffer, int capacity) + { + AssertProperties(buffer, capacity, false, false); + } + + private static void AssertFull(BatchBuffer buffer, int capacity) + { + AssertProperties(buffer, capacity, false, true); + } + + private static void AssertProperties(BatchBuffer buffer, int capacity, bool empty, bool full) + { + using (new AssertionScope()) + { + buffer.Capacity.Should().Be(capacity); + buffer.IsEmpty.Should().Be(empty); + buffer.IsFull.Should().Be(full); + } + } +} diff --git a/test/Sentry.Tests/Internals/BatchProcessorTests.cs b/test/Sentry.Tests/Internals/BatchProcessorTests.cs new file mode 100644 index 0000000000..f4515bab68 --- /dev/null +++ b/test/Sentry.Tests/Internals/BatchProcessorTests.cs @@ -0,0 +1,219 @@ +#nullable enable + +namespace Sentry.Tests.Internals; + +public class BatchProcessorTests : IDisposable +{ + private readonly IHub _hub; + private readonly MockClock _clock; + private readonly ClientReportRecorder _clientReportRecorder; + private readonly InMemoryDiagnosticLogger _diagnosticLogger; + private readonly BlockingCollection _capturedEnvelopes; + + private int _expectedDiagnosticLogs; + + public BatchProcessorTests() + { + var options = new SentryOptions(); + + _hub = Substitute.For(); + _clock = new MockClock(); + _clientReportRecorder = new ClientReportRecorder(options, _clock); + _diagnosticLogger = new InMemoryDiagnosticLogger(); + + _capturedEnvelopes = []; + _hub.CaptureEnvelope(Arg.Do(arg => _capturedEnvelopes.Add(arg))); + + _expectedDiagnosticLogs = 0; + } + + [Theory(Skip = "May no longer be required after feedback.")] + [InlineData(-1)] + [InlineData(0)] + public void Ctor_CountOutOfRange_Throws(int count) + { + var ctor = () => new BatchProcessor(_hub, count, TimeSpan.FromMilliseconds(10), _clock, _clientReportRecorder, _diagnosticLogger); + + Assert.Throws(ctor); + } + + [Theory(Skip = "May no longer be required after feedback.")] + [InlineData(-1)] + [InlineData(0)] + [InlineData(int.MaxValue + 1.0)] + public void Ctor_IntervalOutOfRange_Throws(double interval) + { + var ctor = () => new BatchProcessor(_hub, 1, TimeSpan.FromMilliseconds(interval), _clock, _clientReportRecorder, _diagnosticLogger); + + Assert.Throws(ctor); + } + + [Fact] + public void Enqueue_NeitherSizeNorTimeoutReached_DoesNotCaptureEnvelope() + { + using var processor = new BatchProcessor(_hub, 2, Timeout.InfiniteTimeSpan, _clock, _clientReportRecorder, _diagnosticLogger); + + processor.Enqueue(CreateLog("one")); + + Assert.Empty(_capturedEnvelopes); + AssertEnvelope(); + } + + [Fact] + public void Enqueue_SizeReached_CaptureEnvelope() + { + using var processor = new BatchProcessor(_hub, 2, Timeout.InfiniteTimeSpan, _clock, _clientReportRecorder, _diagnosticLogger); + + processor.Enqueue(CreateLog("one")); + processor.Enqueue(CreateLog("two")); + + Assert.Single(_capturedEnvelopes); + AssertEnvelope("one", "two"); + } + + [Fact] + public void Enqueue_TimeoutReached_CaptureEnvelope() + { + using var processor = new BatchProcessor(_hub, 2, Timeout.InfiniteTimeSpan, _clock, _clientReportRecorder, _diagnosticLogger); + + processor.Enqueue(CreateLog("one")); + + processor.OnIntervalElapsed(null); + + Assert.Single(_capturedEnvelopes); + AssertEnvelope("one"); + } + + [Fact] + public void Enqueue_BothSizeAndTimeoutReached_CaptureEnvelopeOnce() + { + using var processor = new BatchProcessor(_hub, 2, Timeout.InfiniteTimeSpan, _clock, _clientReportRecorder, _diagnosticLogger); + + processor.Enqueue(CreateLog("one")); + processor.Enqueue(CreateLog("two")); + processor.OnIntervalElapsed(null); + + Assert.Single(_capturedEnvelopes); + AssertEnvelope("one", "two"); + } + + [Fact] + public void Enqueue_BothTimeoutAndSizeReached_CaptureEnvelopes() + { + using var processor = new BatchProcessor(_hub, 2, Timeout.InfiniteTimeSpan, _clock, _clientReportRecorder, _diagnosticLogger); + + processor.OnIntervalElapsed(null); + processor.Enqueue(CreateLog("one")); + processor.OnIntervalElapsed(null); + processor.Enqueue(CreateLog("two")); + processor.Enqueue(CreateLog("three")); + + Assert.Equal(2, _capturedEnvelopes.Count); + AssertEnvelopes(["one"], ["two", "three"]); + } + + [Fact(Skip = "TODO")] + public async Task Enqueue_Concurrency_CaptureEnvelopes() + { + const int batchCount = 3; + const int logsPerTask = 100; + + using var processor = new BatchProcessor(_hub, batchCount, Timeout.InfiniteTimeSpan, _clock, _clientReportRecorder, _diagnosticLogger); + using var sync = new ManualResetEvent(false); + + var tasks = new Task[5]; + for (var i = 0; i < tasks.Length; i++) + { + tasks[i] = Task.Factory.StartNew(static state => + { + var (sync, logsPerTask, taskIndex, processor) = ((ManualResetEvent, int, int, BatchProcessor))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)); + _capturedEnvelopes.CompleteAdding(); + + var capturedLogs = _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 (_clientReportRecorder.GenerateClientReport() is { } clientReport) + { + var discardedEvent = Assert.Single(clientReport.DiscardedEvents); + Assert.Equal(new DiscardReasonWithCategory(DiscardReason.Backpressure, DataCategory.Default), discardedEvent.Key); + + droppedLogs = discardedEvent.Value; + _expectedDiagnosticLogs = discardedEvent.Value; + } + + var actualInvocations = tasks.Length * 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. + """); + } + } + + 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(_capturedEnvelopes); + return; + } + + var envelope = Assert.Single(_capturedEnvelopes); + AssertEnvelope(envelope, expected); + } + + private void AssertEnvelopes(params string[][] expected) + { + if (expected.Length == 0) + { + Assert.Empty(_capturedEnvelopes); + return; + } + + Assert.Equal(expected.Length, _capturedEnvelopes.Count); + var index = 0; + foreach (var capturedEnvelope in _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)); + } + + public void Dispose() + { + Assert.Equal(_expectedDiagnosticLogs, _diagnosticLogger.Entries.Count); + } +} 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/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..4fd355839b --- /dev/null +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -0,0 +1,471 @@ +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, 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("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(["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.ToUnixTimeSeconds()}}, + "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("string", false, 1, 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.ToUnixTimeSeconds()}}, + "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 = + [ + sbyte.MinValue, + byte.MaxValue, + short.MinValue, + ushort.MaxValue, + int.MinValue, + uint.MaxValue, + long.MinValue, + ulong.MaxValue, +#if NET5_0_OR_GREATER + nint.MinValue, + nuint.MaxValue, +#endif + 1f, + 2d, + 3m, + true, + 'c', + "string", +#if (NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) + KeyValuePair.Create("key", "value"), +#else + new KeyValuePair("key", "value"), +#endif + null, + ], + }; + + var currentParameterAttributeIndex = -1; + string GetNextParameterAttributeName() => $"sentry.message.parameter.{++currentParameterAttributeIndex}"; + + 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(GetNextParameterAttributeName(), json => json.GetSByte(), sbyte.MinValue), + property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetByte(), byte.MaxValue), + property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetInt16(), short.MinValue), + property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetUInt16(), ushort.MaxValue), + property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetInt32(), int.MinValue), + property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetUInt32(), uint.MaxValue), + property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetInt64(), long.MinValue), + property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), ulong.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), +#if NET5_0_OR_GREATER + property => property.AssertAttributeInteger(GetNextParameterAttributeName(), json => json.GetInt64(), nint.MinValue), + property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), nuint.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), +#endif + property => property.AssertAttributeDouble(GetNextParameterAttributeName(), json => json.GetSingle(), 1f), + property => property.AssertAttributeDouble(GetNextParameterAttributeName(), json => json.GetDouble(), 2d), + property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), 3m.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeBoolean(GetNextParameterAttributeName(), json => json.GetBoolean(), true), + property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), "c"), + property => property.AssertAttributeString(GetNextParameterAttributeName(), json => json.GetString(), "string"), + property => property.AssertAttributeString(GetNextParameterAttributeName(), 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 JsonFormatterExtensions +{ + public static string Format(this DateTimeOffset value) + { + return value.ToString("yyyy-MM-ddTHH:mm:sszzz", 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..f9f82710cb --- /dev/null +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -0,0 +1,312 @@ +#nullable enable + +namespace Sentry.Tests; + +/// +/// +/// +public class SentryStructuredLoggerTests +{ + 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, TimeSpan.Zero)); + TraceId = SentryId.Create(); + ParentSpanId = SpanId.Create(); + + 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 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); + } + + private readonly Fixture _fixture; + + public SentryStructuredLoggerTests() + { + _fixture = new Fixture(); + } + + [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(Skip = "Remove InternalBatchSize")] + [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; + _fixture.Options.Experimental.InternalBatchSize = 1; + 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); + + _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(Skip = "Remove InternalBatchSize")] + public void Log_WithoutTraceHeader_CapturesEnvelope() + { + _fixture.WithoutTraceHeader(); + _fixture.Options.Experimental.EnableLogs = true; + _fixture.Options.Experimental.InternalBatchSize = 1; + 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); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + envelope.AssertEnvelope(_fixture, SentryLogLevel.Trace); + } + + [Fact(Skip = "Remove InternalBatchSize")] + public void Log_WithBeforeSendLog_InvokesCallback() + { + var invocations = 0; + SentryLog configuredLog = null!; + + _fixture.Options.Experimental.EnableLogs = true; + _fixture.Options.Experimental.InternalBatchSize = 1; + _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); + + _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.Entries.Should().ContainSingle().Which; + 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.Entries.Should().ContainSingle().Which; + 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.Entries.Should().ContainSingle().Which; + 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(Skip = "May no longer be required after feedback.")] + public void Dispose_Log_Throws() + { + _fixture.Options.Experimental.EnableLogs = true; + var logger = _fixture.GetSut(); + + logger.Dispose(); + var log = () => logger.LogTrace("Template string with arguments: {0}, {1}, {2}, {3}", ["string", true, 1, 2.2], ConfigureLog); + + Assert.Throws(log); + } + + 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 object[] { "string", true, 1, 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); + } + } +}