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 index 5f6bd64064..9dadb3108b 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -1,6 +1,5 @@ using Sentry.Extensibility; using Sentry.Infrastructure; -using Sentry.Protocol.Envelopes; namespace Sentry.Internal; @@ -10,6 +9,8 @@ internal sealed class DefaultSentryStructuredLogger : SentryStructuredLogger 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 }); @@ -17,6 +18,24 @@ internal DefaultSentryStructuredLogger(IHub hub, SentryOptions options, ISystemC _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) @@ -71,9 +90,17 @@ private protected override void CaptureLog(SentryLogLevel level, string template if (configuredLog is not null) { - //TODO: enqueue in Batch-Processor / Background-Worker - // see https://github.com/getsentry/sentry-dotnet/issues/4132 - _ = _hub.CaptureEnvelope(Envelope.FromLog(configuredLog)); + _batchProcessor.Enqueue(configuredLog); } } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _batchProcessor.Dispose(); + } + + base.Dispose(disposing); + } } 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 95d3e4b289..fb13510789 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -796,6 +796,8 @@ public void Dispose() _memoryMonitor?.Dispose(); #endif + Logger.Dispose(); + try { CurrentClient.FlushAsync(_options.ShutdownTimeout).ConfigureAwait(false).GetAwaiter().GetResult(); diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index d9ac774a60..2deca09790 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -445,17 +445,14 @@ internal static Envelope FromClientReport(ClientReport clientReport) return new Envelope(header, items); } - // TODO: This is temporary. We don't expect single log messages to become an envelope by themselves since batching is needed [Experimental(DiagnosticId.ExperimentalFeature)] - internal static Envelope FromLog(SentryLog log) + internal static Envelope FromLog(StructuredLog log) { - //TODO: allow batching Sentry logs - //see https://github.com/getsentry/sentry-dotnet/issues/4132 var header = DefaultHeader; var items = new[] { - EnvelopeItem.FromLog(log) + EnvelopeItem.FromLog(log), }; return new Envelope(header, items); diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index 7da1c7b53a..7528a14d63 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -372,14 +372,12 @@ internal static EnvelopeItem FromClientReport(ClientReport report) } [Experimental(Infrastructure.DiagnosticId.ExperimentalFeature)] - internal static EnvelopeItem FromLog(SentryLog log) + internal static EnvelopeItem FromLog(StructuredLog log) { - //TODO: allow batching Sentry logs - //see https://github.com/getsentry/sentry-dotnet/issues/4132 var header = new Dictionary(3, StringComparer.Ordinal) { [TypeKey] = TypeValueLog, - ["item_count"] = 1, + ["item_count"] = log.Length, ["content_type"] = "application/vnd.sentry.items.log+json", }; 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 index 840bca9967..dab0813c2e 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -9,7 +9,7 @@ namespace Sentry; /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] -public sealed class SentryLog : ISentryJsonSerializable +public sealed class SentryLog { private readonly Dictionary _attributes; @@ -188,12 +188,9 @@ internal void SetDefaultAttributes(SentryOptions options, SdkVersion sdk) } } - /// - public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + internal void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) { writer.WriteStartObject(); - writer.WriteStartArray("items"); - writer.WriteStartObject(); writer.WriteNumber("timestamp", Timestamp.ToUnixTimeSeconds()); @@ -241,10 +238,8 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteEndObject(); } - writer.WriteEndObject(); + writer.WriteEndObject(); // attributes writer.WriteEndObject(); - writer.WriteEndArray(); - writer.WriteEndObject(); } } diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index c64bd6a288..8c1d9e0be0 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -1897,5 +1897,22 @@ 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/SentryStructuredLogger.cs b/src/Sentry/SentryStructuredLogger.cs index f61f9e74da..e63566c3b8 100644 --- a/src/Sentry/SentryStructuredLogger.cs +++ b/src/Sentry/SentryStructuredLogger.cs @@ -8,7 +8,7 @@ namespace Sentry; /// This API is experimental and it may change in the future. /// [Experimental(DiagnosticId.ExperimentalFeature)] -public abstract class SentryStructuredLogger +public abstract class SentryStructuredLogger : IDisposable { internal static SentryStructuredLogger Create(IHub hub, SentryOptions options, ISystemClock clock) { @@ -100,4 +100,19 @@ public void LogFatal(string template, object[]? parameters = null, Action + 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.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 5126f70d58..d16b051657 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -637,7 +637,7 @@ namespace Sentry } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] [System.Runtime.CompilerServices.RequiredMember] - public sealed class SentryLog : Sentry.ISentryJsonSerializable + public sealed class SentryLog { [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] [System.Runtime.CompilerServices.RequiredMember] @@ -661,7 +661,6 @@ namespace Sentry 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) { } - public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public enum SentryLogLevel @@ -834,6 +833,8 @@ namespace Sentry 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) { } } } @@ -1012,8 +1013,10 @@ namespace Sentry public static Sentry.SentryStackTrace FromJson(System.Text.Json.JsonElement json) { } } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public abstract class SentryStructuredLogger + 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")] diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 5126f70d58..d16b051657 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -637,7 +637,7 @@ namespace Sentry } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] [System.Runtime.CompilerServices.RequiredMember] - public sealed class SentryLog : Sentry.ISentryJsonSerializable + public sealed class SentryLog { [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] [System.Runtime.CompilerServices.RequiredMember] @@ -661,7 +661,6 @@ namespace Sentry 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) { } - public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] public enum SentryLogLevel @@ -834,6 +833,8 @@ namespace Sentry 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) { } } } @@ -1012,8 +1013,10 @@ namespace Sentry public static Sentry.SentryStackTrace FromJson(System.Text.Json.JsonElement json) { } } [System.Diagnostics.CodeAnalysis.Experimental("SENTRY0001")] - public abstract class SentryStructuredLogger + 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")] diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index fd6d22e00b..1664c48d92 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -622,7 +622,7 @@ namespace Sentry [System.Runtime.Serialization.EnumMember(Value="fatal")] Fatal = 4, } - public sealed class SentryLog : Sentry.ISentryJsonSerializable + public sealed class SentryLog { public Sentry.SentryLogLevel Level { get; init; } public string Message { get; init; } @@ -633,7 +633,6 @@ namespace Sentry 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 void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } } public enum SentryLogLevel { @@ -796,6 +795,8 @@ namespace Sentry 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) { } } } @@ -972,8 +973,10 @@ 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 + 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) { } diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index 54873dc8bf..b03d14ca22 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -1439,11 +1439,12 @@ public void Logger_IsDisabled_DoesNotCaptureLog() hub.Logger.Should().BeOfType(); } - [Fact] + [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 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/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index 4638d5896f..4fd355839b 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -4,7 +4,8 @@ namespace Sentry.Tests; /// -/// +/// See . +/// See also . /// public class SentryLogTests { @@ -78,7 +79,7 @@ public void WriteTo_Envelope_MinimalSerializedSentryLog() var log = new SentryLog(Timestamp, TraceId, SentryLogLevel.Trace, "message"); log.SetDefaultAttributes(options, new SdkVersion()); - var envelope = Envelope.FromLog(log); + var envelope = Envelope.FromLog(new StructuredLog([log])); using var stream = new MemoryStream(); envelope.Serialize(stream, _output, Clock); @@ -156,7 +157,7 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() 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(log); + var envelope = EnvelopeItem.FromLog(new StructuredLog([log])); using var stream = new MemoryStream(); envelope.Serialize(stream, _output); @@ -251,7 +252,6 @@ public void WriteTo_EnvelopeItem_MaximalSerializedSentryLog() _output.Entries.Should().BeEmpty(); } -#if (NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) //System.Buffers.ArrayBufferWriter [Fact] public void WriteTo_MessageParameters_AsAttributes() { @@ -267,58 +267,62 @@ public void WriteTo_MessageParameters_AsAttributes() 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, ], }; - ArrayBufferWriter bufferWriter = new(); - using Utf8JsonWriter writer = new(bufferWriter); - log.WriteTo(writer, _output); - writer.Flush(); + var currentParameterAttributeIndex = -1; + string GetNextParameterAttributeName() => $"sentry.message.parameter.{++currentParameterAttributeIndex}"; - var document = JsonDocument.Parse(bufferWriter.WrittenMemory); - var items = document.RootElement.GetProperty("items"); - items.GetArrayLength().Should().Be(1); - var attributes = items[0].GetProperty("attributes"); + var document = log.ToJsonDocument(static (obj, writer, logger) => obj.WriteTo(writer, logger), _output); + var attributes = document.RootElement.GetProperty("attributes"); Assert.Collection(attributes.EnumerateObject().ToArray(), - property => property.AssertAttributeInteger("sentry.message.parameter.0", json => json.GetSByte(), sbyte.MinValue), - property => property.AssertAttributeInteger("sentry.message.parameter.1", json => json.GetByte(), byte.MaxValue), - property => property.AssertAttributeInteger("sentry.message.parameter.2", json => json.GetInt16(), short.MinValue), - property => property.AssertAttributeInteger("sentry.message.parameter.3", json => json.GetUInt16(), ushort.MaxValue), - property => property.AssertAttributeInteger("sentry.message.parameter.4", json => json.GetInt32(), int.MinValue), - property => property.AssertAttributeInteger("sentry.message.parameter.5", json => json.GetUInt32(), uint.MaxValue), - property => property.AssertAttributeInteger("sentry.message.parameter.6", json => json.GetInt64(), long.MinValue), - property => property.AssertAttributeString("sentry.message.parameter.7", json => json.GetString(), ulong.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), - property => property.AssertAttributeInteger("sentry.message.parameter.8", json => json.GetInt64(), nint.MinValue), - property => property.AssertAttributeString("sentry.message.parameter.9", json => json.GetString(), nuint.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), - property => property.AssertAttributeDouble("sentry.message.parameter.10", json => json.GetSingle(), 1f), - property => property.AssertAttributeDouble("sentry.message.parameter.11", json => json.GetDouble(), 2d), - property => property.AssertAttributeString("sentry.message.parameter.12", json => json.GetString(), 3m.ToString(NumberFormatInfo.InvariantInfo)), - property => property.AssertAttributeBoolean("sentry.message.parameter.13", json => json.GetBoolean(), true), - property => property.AssertAttributeString("sentry.message.parameter.14", json => json.GetString(), "c"), - property => property.AssertAttributeString("sentry.message.parameter.15", json => json.GetString(), "string"), - property => property.AssertAttributeString("sentry.message.parameter.16", json => json.GetString(), "[key, value]") + 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*") ); } -#endif -#if (NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) //System.Buffers.ArrayBufferWriter [Fact] public void WriteTo_Attributes_AsJson() { @@ -331,26 +335,25 @@ public void WriteTo_Attributes_AsJson() 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!); - ArrayBufferWriter bufferWriter = new(); - using Utf8JsonWriter writer = new(bufferWriter); - log.WriteTo(writer, _output); - writer.Flush(); - - var document = JsonDocument.Parse(bufferWriter.WrittenMemory); - var items = document.RootElement.GetProperty("items"); - items.GetArrayLength().Should().Be(1); - var attributes = items[0].GetProperty("attributes"); + 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), @@ -360,8 +363,10 @@ public void WriteTo_Attributes_AsJson() 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)), @@ -372,13 +377,14 @@ public void WriteTo_Attributes_AsJson() ); 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*") ); } -#endif } file static class AssertExtensions diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 429fa503b5..f9f82710cb 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -74,7 +74,7 @@ public void Create_Disabled_CachedDisabledInstance() instance.Should().BeSameAs(other); } - [Theory] + [Theory(Skip = "Remove InternalBatchSize")] [InlineData(SentryLogLevel.Trace)] [InlineData(SentryLogLevel.Debug)] [InlineData(SentryLogLevel.Info)] @@ -84,6 +84,7 @@ public void Create_Disabled_CachedDisabledInstance() public void Log_Enabled_CapturesEnvelope(SentryLogLevel level) { _fixture.Options.Experimental.EnableLogs = true; + _fixture.Options.Experimental.InternalBatchSize = 1; var logger = _fixture.GetSut(); Envelope envelope = null!; @@ -112,11 +113,12 @@ public void Log_Disabled_DoesNotCaptureEnvelope(SentryLogLevel level) _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); } - [Fact] + [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!; @@ -128,13 +130,14 @@ public void Log_WithoutTraceHeader_CapturesEnvelope() envelope.AssertEnvelope(_fixture, SentryLogLevel.Trace); } - [Fact] + [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++; @@ -218,6 +221,18 @@ public void Log_InvalidBeforeSendLog_DoesNotCaptureEnvelope() 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"); @@ -231,7 +246,7 @@ public static void AssertEnvelope(this Envelope envelope, SentryStructuredLogger 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; + var log = item.Payload.Should().BeOfType().Which.Source.Should().BeOfType().Which; AssertLog(log, fixture, level); Assert.Collection(item.Header, @@ -240,6 +255,13 @@ public static void AssertEnvelope(this Envelope envelope, SentryStructuredLogger 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());