Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Experimental _Structured Logs_:
- Remove `IDisposable` from `SentryStructuredLogger`. Disposal is intended through the owning `IHub` instance. ([#4424](https://github.com/getsentry/sentry-dotnet/pull/4424))
- Ensure all buffered logs are sent to Sentry when the application terminates unexpectedly. ([#4425](https://github.com/getsentry/sentry-dotnet/pull/4425))
- `InvalidOperationException` potentially thrown during a race condition in concurrent high-volume logging scenarios ([#4428](https://github.com/getsentry/sentry-dotnet/pull/4428))

## 5.14.1

Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
```

BenchmarkDotNet v0.13.12, macOS 15.5 (24F74) [Darwin 24.5.0]
BenchmarkDotNet v0.13.12, macOS 15.6 (24G84) [Darwin 24.6.0]
Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores
.NET SDK 9.0.301
[Host] : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD
DefaultJob : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD


```
| Method | BatchCount | OperationsPerInvoke | Mean | Error | StdDev | Gen0 | Allocated |
|---------------- |----------- |-------------------- |------------:|---------:|---------:|-------:|----------:|
| **EnqueueAndFlush** | **10** | **100** | **1,774.5 ns** | **7.57 ns** | **6.71 ns** | **0.6104** | **5 KB** |
| **EnqueueAndFlush** | **10** | **200** | **3,468.5 ns** | **11.16 ns** | **10.44 ns** | **1.2207** | **10 KB** |
| **EnqueueAndFlush** | **10** | **1000** | **17,259.7 ns** | **51.92 ns** | **46.02 ns** | **6.1035** | **50 KB** |
| **EnqueueAndFlush** | **100** | **100** | **857.5 ns** | **4.21 ns** | **3.73 ns** | **0.1469** | **1.2 KB** |
| **EnqueueAndFlush** | **100** | **200** | **1,681.4 ns** | **1.74 ns** | **1.63 ns** | **0.2937** | **2.41 KB** |
| **EnqueueAndFlush** | **100** | **1000** | **8,302.2 ns** | **12.00 ns** | **10.64 ns** | **1.4648** | **12.03 KB** |
| Method | BatchCount | OperationsPerInvoke | Mean | Error | StdDev | Gen0 | Allocated |
|------------------------- |----------- |-------------------- |-------------:|------------:|------------:|-------:|----------:|
| **EnqueueAndFlush** | **10** | **100** | **1,793.4 ns** | **13.75 ns** | **12.86 ns** | **0.6104** | **5 KB** |
| EnqueueAndFlush_Parallel | 10 | 100 | 18,550.8 ns | 368.24 ns | 889.34 ns | 1.1292 | 9.16 KB |
| **EnqueueAndFlush** | **10** | **200** | **3,679.8 ns** | **18.65 ns** | **16.53 ns** | **1.2207** | **10 KB** |
| EnqueueAndFlush_Parallel | 10 | 200 | 41,246.4 ns | 508.07 ns | 475.25 ns | 1.7090 | 14.04 KB |
| **EnqueueAndFlush** | **10** | **1000** | **17,239.1 ns** | **62.50 ns** | **58.46 ns** | **6.1035** | **50 KB** |
| EnqueueAndFlush_Parallel | 10 | 1000 | 192,059.3 ns | 956.92 ns | 895.11 ns | 4.3945 | 37.52 KB |
| **EnqueueAndFlush** | **100** | **100** | **866.7 ns** | **1.99 ns** | **1.77 ns** | **0.1469** | **1.2 KB** |
| EnqueueAndFlush_Parallel | 100 | 100 | 6,714.8 ns | 100.75 ns | 94.24 ns | 0.5569 | 4.52 KB |
| **EnqueueAndFlush** | **100** | **200** | **1,714.5 ns** | **3.20 ns** | **3.00 ns** | **0.2937** | **2.41 KB** |
| EnqueueAndFlush_Parallel | 100 | 200 | 43,842.8 ns | 860.74 ns | 1,718.99 ns | 0.9155 | 7.51 KB |
| **EnqueueAndFlush** | **100** | **1000** | **8,537.8 ns** | **9.80 ns** | **9.17 ns** | **1.4648** | **12.03 KB** |
| EnqueueAndFlush_Parallel | 100 | 1000 | 313,421.4 ns | 6,159.27 ns | 6,846.01 ns | 1.9531 | 18.37 KB |
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using BenchmarkDotNet.Attributes;
using NSubstitute;
using Sentry.Extensibility;
using Sentry.Internal;

Expand Down Expand Up @@ -30,22 +29,10 @@ public void Setup()
};

var batchInterval = Timeout.InfiniteTimeSpan;

var clientReportRecorder = Substitute.For<IClientReportRecorder>();
clientReportRecorder
.When(static recorder => recorder.RecordDiscardedEvent(Arg.Any<DiscardReason>(), Arg.Any<DataCategory>(), Arg.Any<int>()))
.Throw<UnreachableException>();

var diagnosticLogger = Substitute.For<IDiagnosticLogger>();
diagnosticLogger
.When(static logger => logger.IsEnabled(Arg.Any<SentryLevel>()))
.Throw<UnreachableException>();
diagnosticLogger
.When(static logger => logger.Log(Arg.Any<SentryLevel>(), Arg.Any<string>(), Arg.Any<Exception>(), Arg.Any<object[]>()))
.Throw<UnreachableException>();
var clientReportRecorder = new NullClientReportRecorder();

_hub = new Hub(options, DisabledHub.Instance);
_batchProcessor = new StructuredLogBatchProcessor(_hub, BatchCount, batchInterval, clientReportRecorder, diagnosticLogger);
_batchProcessor = new StructuredLogBatchProcessor(_hub, BatchCount, batchInterval, clientReportRecorder, null);
_log = new SentryLog(DateTimeOffset.Now, SentryId.Empty, SentryLogLevel.Trace, "message");
}

Expand All @@ -59,10 +46,38 @@ public void EnqueueAndFlush()
_batchProcessor.Flush();
}

[Benchmark]
public void EnqueueAndFlush_Parallel()
{
_ = Parallel.For(0, OperationsPerInvoke, (int i) =>
{
_batchProcessor.Enqueue(_log);
});
_batchProcessor.Flush();
}

[GlobalCleanup]
public void Cleanup()
{
_batchProcessor.Dispose();
_hub.Dispose();
}
}

file sealed class NullClientReportRecorder : IClientReportRecorder
{
public void RecordDiscardedEvent(DiscardReason reason, DataCategory category, int quantity = 1)
{
// no-op
}

public ClientReport GenerateClientReport()
{
throw new UnreachableException();
}

public void Load(ClientReport report)
{
throw new UnreachableException();
}
}
2 changes: 2 additions & 0 deletions src/Sentry/Internal/StructuredLogBatchBuffer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ namespace Sentry.Internal;
/// <remarks>
/// Must be attempted to flush via <see cref="TryEnterFlushScope"/> when either the <see cref="Capacity"/> is reached,
/// or when the <see cref="_timeout"/> is exceeded.
/// Utilizes a <see cref="ScopedCountdownLock"/>, basically used as an inverse <see cref="ReaderWriterLockSlim"/>,
/// allowing multiple threads for <see cref="Add"/> or exclusive access for <see cref="FlushScope.Flush"/>.
/// </remarks>
[DebuggerDisplay("Name = {Name}, Capacity = {Capacity}, Additions = {_additions}, AddCount = {AddCount}, IsDisposed = {_disposed}")]
internal sealed class StructuredLogBatchBuffer : IDisposable
Expand Down
23 changes: 15 additions & 8 deletions src/Sentry/Threading/ScopedCountdownLock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ internal ScopedCountdownLock()
internal bool IsEngaged => _isEngaged == 1;

/// <summary>
/// No <see cref="CounterScope"/> will be entered when the <see cref="Count"/> has reached <see langword="0"/> while the lock is engaged via an active <see cref="LockScope"/>.
/// No <see cref="CounterScope"/> will be entered when the <see cref="Count"/> has reached <see langword="0"/>, or while the lock is engaged via an active <see cref="LockScope"/>.
/// Check via <see cref="CounterScope.IsEntered"/> whether the underlying <see cref="CountdownEvent"/> has not been set/signaled yet.
/// To signal the underlying <see cref="CountdownEvent"/>, ensure <see cref="CounterScope.Dispose"/> is called.
/// </summary>
Expand All @@ -49,6 +49,11 @@ internal ScopedCountdownLock()
/// </remarks>
internal CounterScope TryEnterCounterScope()
{
if (IsEngaged)
Copy link
Member Author

@Flash0ver Flash0ver Aug 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: additional change

no longer allowing to Add new logs to a particular Buffer, while a Flush has been requested (in this case occurring during the 5 second timeout), which may have previously "dragged out" the Flush by still allowing new Add operations before the Event is actually set by reaching a count of 0 (while the buffer is not full yet)

this also effects #4425, where - in the case of a terminating unhandled exception - no more Add operations are admitted and we only wait until all currently in-progress Add operations have concluded in order to do a "safe" Flush, which should complete quite quickly, not "unreasonably" dragging out the shutdown

{
return new CounterScope();
}

if (_event.TryAddCount(1))
{
return new CounterScope(this);
Expand All @@ -59,6 +64,7 @@ internal CounterScope TryEnterCounterScope()

private void ExitCounterScope()
{
Debug.Assert(_event.CurrentCount >= 1);
_ = _event.Signal();
}

Expand All @@ -75,7 +81,8 @@ internal LockScope TryEnterLockScope()
{
if (Interlocked.CompareExchange(ref _isEngaged, 1, 0) == 0)
{
_ = _event.Signal(); // decrement the initial count of 1, so that the event can be set with the count reaching 0 when all 'CounterScope's have exited
Debug.Assert(_event.CurrentCount >= 1);
_ = _event.Signal(); // decrement the initial count of 1, so that the event can be set with the count reaching 0 when all entered 'CounterScope' instances have exited
return new LockScope(this);
}

Expand All @@ -84,13 +91,13 @@ internal LockScope TryEnterLockScope()

private void ExitLockScope()
{
if (Interlocked.CompareExchange(ref _isEngaged, 0, 1) == 1)
Debug.Assert(_event.IsSet);
_event.Reset(); // reset the signaled event to the initial count of 1, so that new 'CounterScope' instances can be entered again
Copy link
Member Author

@Flash0ver Flash0ver Aug 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: fix

fixed by performing the "Reset before CompareExchange" when existing in the reverse order than the "CompareExchange before Signal" when entering


if (Interlocked.CompareExchange(ref _isEngaged, 0, 1) != 1)
{
_event.Reset(); // reset the signaled event to the initial count of 1, so that new `CounterScope`s can be entered again
return;
Debug.Fail("The Lock should have not been disengaged without being engaged first.");
}

Debug.Fail("The Lock should have not been disengaged without being engaged first.");
}

/// <inheritdoc />
Expand Down Expand Up @@ -139,7 +146,7 @@ internal LockScope(ScopedCountdownLock lockObj)
internal void Wait()
{
var lockObj = _lockObj;
lockObj?._event.Wait();
lockObj?._event.Wait(Timeout.Infinite, CancellationToken.None);
Copy link
Member Author

@Flash0ver Flash0ver Aug 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this is not a behavioral change .. just making the behavior more apparent by "lifting" the method call of the parameterless Wait method

}

public void Dispose()
Expand Down
14 changes: 7 additions & 7 deletions test/Sentry.Tests/Threading/ScopedCountdownLockTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,20 +79,20 @@ public void TryEnterLockScope_IsEngaged_IsSet()
lockTwo.Dispose();
AssertEngaged(false, 1);

// successfully enter another CounterScope ... lock is engaged but not yet set
// cannot enter another CounterScope as long as the lock is already engaged by a LockScope
var counterTwo = _lock.TryEnterCounterScope();
counterTwo.IsEntered.Should().BeTrue();
AssertEngaged(false, 2);
counterTwo.IsEntered.Should().BeFalse();
AssertEngaged(false, 1);

// exit a CounterScope ... decrement the count
// no-op ... CounterScope is not entered
counterTwo.Dispose();
AssertEngaged(false, 1);

// exit last CounterScope ... count of engaged lock reaches zero ... sets the lock
counterOne.Dispose();
AssertEngaged(true, 0);

// cannot enter another CounterScope as long as the engaged lock is set
// cannot enter another CounterScope as long as the lock is engaged and set
var counterThree = _lock.TryEnterCounterScope();
counterThree.IsEntered.Should().BeFalse();
AssertEngaged(true, 0);
Expand All @@ -102,11 +102,11 @@ public void TryEnterLockScope_IsEngaged_IsSet()
// would block if the count of the engaged lock was not zero
lockOne.Wait();

// exit the LockScope ... reset the lock
// exit the LockScope ... reset and disengage the lock
lockOne.Dispose();
AssertDisengaged(false, 0);

// can enter a CounterScope again ... the lock not set
// can enter a CounterScope again ... the lock is no longer set and no longer engaged
var counterFour = _lock.TryEnterCounterScope();
counterFour.IsEntered.Should().BeTrue();
AssertDisengaged(false, 1);
Expand Down
Loading