diff --git a/.generated.NoMobile.sln b/.generated.NoMobile.sln index 82631572fc..a90cf85ad2 100644 --- a/.generated.NoMobile.sln +++ b/.generated.NoMobile.sln @@ -178,6 +178,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "root", "root", "{233D34AB-9 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.AspNetCore.Blazor.WebAssembly", "src\Sentry.AspNetCore.Blazor.WebAssembly\Sentry.AspNetCore.Blazor.WebAssembly.csproj", "{8298202C-9983-4D0A-851D-805539EE481A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Samples.Console.HeapDump", "samples\Sentry.Samples.Console.HeapDump\Sentry.Samples.Console.HeapDump.csproj", "{D7DF0B26-AD43-4F8B-9BFE-C4471CCC9821}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -493,6 +495,10 @@ Global {A5B26C14-7313-4EDC-91E3-287F9374AB75}.Debug|Any CPU.Build.0 = Debug|Any CPU {A5B26C14-7313-4EDC-91E3-287F9374AB75}.Release|Any CPU.ActiveCfg = Release|Any CPU {A5B26C14-7313-4EDC-91E3-287F9374AB75}.Release|Any CPU.Build.0 = Release|Any CPU + {D7DF0B26-AD43-4F8B-9BFE-C4471CCC9821}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D7DF0B26-AD43-4F8B-9BFE-C4471CCC9821}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D7DF0B26-AD43-4F8B-9BFE-C4471CCC9821}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D7DF0B26-AD43-4F8B-9BFE-C4471CCC9821}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -575,5 +581,6 @@ Global {46E40BE8-1AB0-4846-B0A2-A40AD0272C64} = {6987A1CC-608E-4868-A02C-09D30C8B7B2D} {8298202C-9983-4D0A-851D-805539EE481A} = {230B9384-90FD-4551-A5DE-1A5C197F25B6} {A5B26C14-7313-4EDC-91E3-287F9374AB75} = {21B42F60-5802-404E-90F0-AEBCC56760C0} + {D7DF0B26-AD43-4F8B-9BFE-C4471CCC9821} = {21B42F60-5802-404E-90F0-AEBCC56760C0} EndGlobalSection EndGlobal diff --git a/CHANGELOG.md b/CHANGELOG.md index cfeccd882b..d9876f0fa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - `SentryOptions.EnableTracing` has been removed. Instead, tracing should be enabled or disabled by setting the `SentryOptions.TracesSampleRate` or by using `SentryOptions.TracesSampler` to configure a sampling function ([#3569](https://github.com/getsentry/sentry-dotnet/pull/3569)) - The `FailedRequestTargets`, `TagFilters` and `TracePropagationTargets` options have all been changed from `SubstringOrRegexPattern` to `IList` ([#3566](https://github.com/getsentry/sentry-dotnet/pull/3566)) - `Scope.Transaction` is now always stored as an `AsyncLocal` also in [Global Mode](https://docs.sentry.io/platforms/dotnet/configuration/options/#is-global-mode-enabled), to prevent auto-instrumented spans from the UI ending up parented to transactions from a background task (or vice versa). ([#3596](https://github.com/getsentry/sentry-dotnet/pull/3596)) +- Heap dumps can be captured automatically when memory usage exceeds a configurable threshold ([#3667](https://github.com/getsentry/sentry-dotnet/pull/3667)) - Sentry's Experimental Metrics feature has been deprecated and removed from the SDK. ([#3718](https://github.com/getsentry/sentry-dotnet/pull/3718)) ### Features diff --git a/Directory.Build.props b/Directory.Build.props index d439f8d5ad..d10026aaad 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -84,6 +84,15 @@ $(MSBuildThisFileDirectory)tools\sentry-cli\$(SentryCLIVersion)\ + + + true + true + true + + $(DefineConstants);MEMORY_DUMP_SUPPORTED + + 002400000480000094000000060200000024000052534131000400000100010059964a931488bcdbd14657f1ee0df32df61b57b3d14d7290c262c2cc9ddaad6ec984044f761f778e1823049d2cb996a4f58c8ea5b46c37891414cb34b4036b1c178d7b582289d2eef3c0f1e9b692c229a306831ee3d371d9e883f0eb0f74aeac6c6ab8c85fd1ec04b267e15a31532c4b4e2191f5980459db4dce0081f1050fb8 diff --git a/Sentry-CI-Build-Linux.slnf b/Sentry-CI-Build-Linux.slnf index d772922ad2..44fdaabe1b 100644 --- a/Sentry-CI-Build-Linux.slnf +++ b/Sentry-CI-Build-Linux.slnf @@ -15,6 +15,7 @@ "samples\\Sentry.Samples.Azure.Functions.Worker\\Sentry.Samples.Azure.Functions.Worker.csproj", "samples\\Sentry.Samples.Console.Basic\\Sentry.Samples.Console.Basic.csproj", "samples\\Sentry.Samples.Console.Customized\\Sentry.Samples.Console.Customized.csproj", + "samples\\Sentry.Samples.Console.HeapDump\\Sentry.Samples.Console.HeapDump.csproj", "samples\\Sentry.Samples.Console.Native\\Sentry.Samples.Console.Native.csproj", "samples\\Sentry.Samples.Console.Profiling\\Sentry.Samples.Console.Profiling.csproj", "samples\\Sentry.Samples.EntityFramework\\Sentry.Samples.EntityFramework.csproj", diff --git a/Sentry-CI-Build-Windows.slnf b/Sentry-CI-Build-Windows.slnf index 061bba0a20..00f48bf6de 100644 --- a/Sentry-CI-Build-Windows.slnf +++ b/Sentry-CI-Build-Windows.slnf @@ -16,6 +16,7 @@ "samples\\Sentry.Samples.Azure.Functions.Worker\\Sentry.Samples.Azure.Functions.Worker.csproj", "samples\\Sentry.Samples.Console.Basic\\Sentry.Samples.Console.Basic.csproj", "samples\\Sentry.Samples.Console.Customized\\Sentry.Samples.Console.Customized.csproj", + "samples\\Sentry.Samples.Console.HeapDump\\Sentry.Samples.Console.HeapDump.csproj", "samples\\Sentry.Samples.Console.Native\\Sentry.Samples.Console.Native.csproj", "samples\\Sentry.Samples.Console.Profiling\\Sentry.Samples.Console.Profiling.csproj", "samples\\Sentry.Samples.EntityFramework\\Sentry.Samples.EntityFramework.csproj", diff --git a/Sentry-CI-Build-macOS.slnf b/Sentry-CI-Build-macOS.slnf index 0ed945bc3b..b62140126c 100644 --- a/Sentry-CI-Build-macOS.slnf +++ b/Sentry-CI-Build-macOS.slnf @@ -16,6 +16,7 @@ "samples\\Sentry.Samples.Azure.Functions.Worker\\Sentry.Samples.Azure.Functions.Worker.csproj", "samples\\Sentry.Samples.Console.Basic\\Sentry.Samples.Console.Basic.csproj", "samples\\Sentry.Samples.Console.Customized\\Sentry.Samples.Console.Customized.csproj", + "samples\\Sentry.Samples.Console.HeapDump\\Sentry.Samples.Console.HeapDump.csproj", "samples\\Sentry.Samples.Console.Native\\Sentry.Samples.Console.Native.csproj", "samples\\Sentry.Samples.Console.Profiling\\Sentry.Samples.Console.Profiling.csproj", "samples\\Sentry.Samples.EntityFramework\\Sentry.Samples.EntityFramework.csproj", diff --git a/Sentry.sln b/Sentry.sln index a2416a2434..bce391e0e8 100644 --- a/Sentry.sln +++ b/Sentry.sln @@ -187,6 +187,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "root", "root", "{233D34AB-9 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.AspNetCore.Blazor.WebAssembly", "src\Sentry.AspNetCore.Blazor.WebAssembly\Sentry.AspNetCore.Blazor.WebAssembly.csproj", "{8298202C-9983-4D0A-851D-805539EE481A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Samples.Console.HeapDump", "samples\Sentry.Samples.Console.HeapDump\Sentry.Samples.Console.HeapDump.csproj", "{D7DF0B26-AD43-4F8B-9BFE-C4471CCC9821}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -502,6 +504,10 @@ Global {A5B26C14-7313-4EDC-91E3-287F9374AB75}.Debug|Any CPU.Build.0 = Debug|Any CPU {A5B26C14-7313-4EDC-91E3-287F9374AB75}.Release|Any CPU.ActiveCfg = Release|Any CPU {A5B26C14-7313-4EDC-91E3-287F9374AB75}.Release|Any CPU.Build.0 = Release|Any CPU + {D7DF0B26-AD43-4F8B-9BFE-C4471CCC9821}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D7DF0B26-AD43-4F8B-9BFE-C4471CCC9821}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D7DF0B26-AD43-4F8B-9BFE-C4471CCC9821}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D7DF0B26-AD43-4F8B-9BFE-C4471CCC9821}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -584,5 +590,6 @@ Global {46E40BE8-1AB0-4846-B0A2-A40AD0272C64} = {6987A1CC-608E-4868-A02C-09D30C8B7B2D} {8298202C-9983-4D0A-851D-805539EE481A} = {230B9384-90FD-4551-A5DE-1A5C197F25B6} {A5B26C14-7313-4EDC-91E3-287F9374AB75} = {21B42F60-5802-404E-90F0-AEBCC56760C0} + {D7DF0B26-AD43-4F8B-9BFE-C4471CCC9821} = {21B42F60-5802-404E-90F0-AEBCC56760C0} EndGlobalSection EndGlobal diff --git a/Sentry.sln.DotSettings b/Sentry.sln.DotSettings index 3491ad6fec..a06bb386ee 100644 --- a/Sentry.sln.DotSettings +++ b/Sentry.sln.DotSettings @@ -4,6 +4,7 @@ OS QL UI + True True True True diff --git a/SentryNoMobile.slnf b/SentryNoMobile.slnf index 7c5571e30a..425f20e7f8 100644 --- a/SentryNoMobile.slnf +++ b/SentryNoMobile.slnf @@ -14,6 +14,7 @@ "samples\\Sentry.Samples.Azure.Functions.Worker\\Sentry.Samples.Azure.Functions.Worker.csproj", "samples\\Sentry.Samples.Console.Basic\\Sentry.Samples.Console.Basic.csproj", "samples\\Sentry.Samples.Console.Customized\\Sentry.Samples.Console.Customized.csproj", + "samples\\Sentry.Samples.Console.HeapDump\\Sentry.Samples.Console.HeapDump.csproj", "samples\\Sentry.Samples.Console.Native\\Sentry.Samples.Console.Native.csproj", "samples\\Sentry.Samples.Console.Profiling\\Sentry.Samples.Console.Profiling.csproj", "samples\\Sentry.Samples.EntityFramework\\Sentry.Samples.EntityFramework.csproj", diff --git a/samples/Directory.Build.props b/samples/Directory.Build.props index 87af636244..828eba441b 100644 --- a/samples/Directory.Build.props +++ b/samples/Directory.Build.props @@ -7,7 +7,7 @@ - + false diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 0cd25ae811..be18640323 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -8,10 +8,10 @@ * For more advanced features of the SDK, see Sentry.Samples.Console.Customized. */ -// Initialize the Sentry SDK. (It is not necessary to dispose it.) - using System.Net.Http; +using static System.Console; +// Initialize the Sentry SDK. (It is not necessary to dispose it.) SentrySdk.Init(options => { // You can set here in code, or you can set it in the SENTRY_DSN environment variable. @@ -55,7 +55,7 @@ async Task FirstFunction() var messageHandler = new SentryHttpMessageHandler(); var httpClient = new HttpClient(messageHandler, true); var html = await httpClient.GetStringAsync("https://example.com/"); - Console.WriteLine(html); + WriteLine(html); } async Task SecondFunction() diff --git a/samples/Sentry.Samples.Console.HeapDump/Program.cs b/samples/Sentry.Samples.Console.HeapDump/Program.cs new file mode 100644 index 0000000000..ef5138c405 --- /dev/null +++ b/samples/Sentry.Samples.Console.HeapDump/Program.cs @@ -0,0 +1,72 @@ +/* + * This sample demonstrates how you can configure Sentry to automatically capture heap dumps based on certain memory + * triggers (e.g. if memory consumption exceeds a certain percentage threshold). + * + * Note that this functionality is only available when targeting net6.0 or above and is not available on iOS, Android + * or Mac Catalyst. + */ + +using System.Reflection; +using static System.Console; + +var cts = new CancellationTokenSource(); + +// Initialize the Sentry SDK. (It is not necessary to dispose it.) +SentrySdk.Init(options => +{ + // You can set here in code, or you can set it in the SENTRY_DSN environment variable. + // See https://docs.sentry.io/product/sentry-basics/dsn-explainer/ + options.Dsn = "https://eb18e953812b41c3aeb042e666fd3b5c@o447951.ingest.sentry.io/5428537"; + + // When debug is enabled, the Sentry client will emit detailed debugging information to the console. + // This might be helpful, or might interfere with the normal operation of your application. + // We enable it here for demonstration purposes. + // You should not do this in your applications unless you are troubleshooting issues with Sentry. + options.Debug = true; + + // Set TracesSampleRate = 0 to disable tracing for this demo + options.TracesSampleRate = 0; + + // This option tells Sentry to capture a heap dump and send these as a file attachment in a Sentry event + options.EnableHeapDumps( + // Triggers a heap dump if the process uses more than 5% of the total memory. We could use any threshold or even + // provide a custom trigger function here instead. + 5, + // Limit the frequency of heap dumps to a maximum of 3 events per day and at least 1 hour between each event. + Debouncer.PerDay(3, TimeSpan.FromHours(1)), + // Set the level for heap dump events to Info + SentryLevel.Info + ); + + // This is an example of intercepting events before they get sent to Sentry. Typically, you might use this to + // filter events that you didn't want to send but in this case we're using it to detect when a heap dump has + // been captured, so we know when to stop allocating memory in the heap dump demo. + options.SetBeforeSend((evt, hint) => + { + if (hint.Attachments.Any(a => a.FileName.EndsWith("gcdump"))) + { + cts.Cancel(); + } + return evt; // If we returned null here, that would stop the event from being sent + }); +}); + +// In Debug mode there will be a bit of stuff logged out during initialization... wait for that to play out +await Task.Delay(1000); + +var memoryHog = new List(); +WriteLine(); + +WriteLine("Hogging memory..."); + +// Sentry checks memory usage every time a full garbage collection occurs. It might take a while to trigger this, +// although we've configured some ridiculously aggressive settings in the runtimeconfig.template.json file to make +// this happen more quickly, for the purposes of this demo... definitely don't do this in production! +while (cts.Token.IsCancellationRequested == false) +{ + var array = new byte[2_000_000_000]; + array.Initialize(); + memoryHog.Add(array); +} + +GC.KeepAlive(memoryHog); diff --git a/samples/Sentry.Samples.Console.HeapDump/Sentry.Samples.Console.HeapDump.csproj b/samples/Sentry.Samples.Console.HeapDump/Sentry.Samples.Console.HeapDump.csproj new file mode 100644 index 0000000000..cfcc72e664 --- /dev/null +++ b/samples/Sentry.Samples.Console.HeapDump/Sentry.Samples.Console.HeapDump.csproj @@ -0,0 +1,55 @@ + + + + Exe + net8.0 + enable + enable + true + + + + + sentry-sdks + sentry-dotnet + + + true + true + + + true + + + true + --local + + + + + + + + + + + + diff --git a/samples/Sentry.Samples.Console.HeapDump/runtimeconfig.template.json b/samples/Sentry.Samples.Console.HeapDump/runtimeconfig.template.json new file mode 100644 index 0000000000..e33343cc37 --- /dev/null +++ b/samples/Sentry.Samples.Console.HeapDump/runtimeconfig.template.json @@ -0,0 +1,6 @@ +{ + "configProperties": { + "System.GC.ConserveMemory": 9, + "System.GC.HighMemoryPercent": 20 + } +} diff --git a/src/Sentry/Debouncer.cs b/src/Sentry/Debouncer.cs new file mode 100644 index 0000000000..0bd4e68ae9 --- /dev/null +++ b/src/Sentry/Debouncer.cs @@ -0,0 +1,103 @@ +namespace Sentry; + +/// +/// A debouncer that can be used to limit the number of occurrences of an event within a given interval and optionally, +/// enforce a minimum cooldown period between events. +/// +public class Debouncer +{ + internal enum DebouncerInterval { Minute, Hour, Day, ApplicationLifetime } + + internal DateTimeOffset _intervalStart = DateTimeOffset.MinValue; + internal DateTimeOffset _lastEvent = DateTimeOffset.MinValue; + internal int _occurrences; + + internal readonly DebouncerInterval _intervalType; + internal readonly int _eventMaximum; + private readonly TimeSpan? _cooldown; + + private Debouncer(DebouncerInterval intervalType, int eventMaximum = 1, TimeSpan? cooldown = null) + { + _intervalType = intervalType; + _cooldown = cooldown; + _eventMaximum = eventMaximum; + } + + /// + /// Creates a debouncer that limits the number of events per minute + /// + /// The maximum number of events that will be processed per minute + /// An optional obligatory cooldown since the last event before any other events will be processed + /// + public static Debouncer PerMinute(int eventMaximum = 1, TimeSpan? cooldown = null) + => new(DebouncerInterval.Minute, eventMaximum, cooldown); + + /// + /// Creates a debouncer that limits the number of events per hour + /// + /// The maximum number of events that will be processed per hour + /// An optional obligatory cooldown since the last event before any other events will be processed + /// + public static Debouncer PerHour(int eventMaximum = 1, TimeSpan? cooldown = null) + => new(DebouncerInterval.Hour, eventMaximum, cooldown); + + /// + /// Creates a debouncer that limits the number of events per day + /// + /// The maximum number of events that will be processed per day + /// An optional obligatory cooldown since the last event before any other events will be processed + /// + public static Debouncer PerDay(int eventMaximum = 1, TimeSpan? cooldown = null) + => new(DebouncerInterval.Day, eventMaximum, cooldown); + + /// + /// Creates a debouncer that limits the number of events that will be processed for the lifetime of the application + /// + /// The maximum number of events that will be processed + /// An optional obligatory cooldown since the last event before any other events will be processed + /// + public static Debouncer PerApplicationLifetime(int eventMaximum = 1, TimeSpan? cooldown = null) + => new(DebouncerInterval.ApplicationLifetime, eventMaximum, cooldown); + + private TimeSpan IntervalTimeSpan() + { + switch (_intervalType) + { + case DebouncerInterval.Minute: + return TimeSpan.FromMinutes(1); + case DebouncerInterval.Hour: + return TimeSpan.FromHours(1); + case DebouncerInterval.Day: + return TimeSpan.FromDays(1); + case DebouncerInterval.ApplicationLifetime: + return TimeSpan.MaxValue; + default: + throw new ArgumentOutOfRangeException(nameof(_intervalType)); + } + } + + internal void RecordOccurence(DateTimeOffset? timestamp = null) + { + var eventTime = timestamp ?? DateTimeOffset.UtcNow; + + if (eventTime - _intervalStart >= IntervalTimeSpan()) + { + _intervalStart = eventTime; + _occurrences = 0; + } + + _occurrences++; + _lastEvent = eventTime; + } + + internal bool CanProcess(DateTimeOffset? timestamp = null) + { + if (_occurrences >= _eventMaximum) + { + return false; + } + + var eventTime = timestamp ?? DateTimeOffset.UtcNow; + return _cooldown is not { } cooldown || _lastEvent + cooldown <= eventTime; + } +} diff --git a/src/Sentry/HeapDumpTriggers.cs b/src/Sentry/HeapDumpTriggers.cs new file mode 100644 index 0000000000..411270b6ef --- /dev/null +++ b/src/Sentry/HeapDumpTriggers.cs @@ -0,0 +1,27 @@ +namespace Sentry; + +/// +/// Delegate that determines whether a heap dump should be triggered or not. +/// +/// Memory currently used by the process +/// Total available memory +/// if the heap dump should be triggered; otherwise, . +public delegate bool HeapDumpTrigger(long usedMemory, long totalMemory); + +internal static class HeapDumpTriggers +{ + internal static HeapDumpTrigger MemoryPercentageThreshold(int memoryPercentageThreshold) + { + if (memoryPercentageThreshold is < 0 or > 100) + { + throw new ArgumentException("Must be a value between 0 and 100", nameof(memoryPercentageThreshold)); + } + + return (long usedMemory, long totalMemory) => + { + var portion = (double)memoryPercentageThreshold / 100; + var thresholdBytes = (long)Math.Ceiling(portion * totalMemory); + return usedMemory > thresholdBytes; + }; + } +} diff --git a/src/Sentry/Internal/GarbageCollectionMonitor.cs b/src/Sentry/Internal/GarbageCollectionMonitor.cs new file mode 100644 index 0000000000..a17486b8fd --- /dev/null +++ b/src/Sentry/Internal/GarbageCollectionMonitor.cs @@ -0,0 +1,39 @@ +using Sentry.Extensibility; + +namespace Sentry.Internal; + +/// +/// Simple class to detect when Full Garbage Collection occurs +/// +internal sealed class GarbageCollectionMonitor +{ + private const int MaxGenerationThreshold = 10; + private const int LargeObjectHeapThreshold = 10; + + public static Task Start(Action onGarbageCollected, CancellationToken cancellationToken, IGCImplementation? gc = null) => + Task.Run(() => MonitorGarbageCollection(onGarbageCollected, cancellationToken, gc), cancellationToken); + + private static void MonitorGarbageCollection(Action onGarbageCollected, CancellationToken cancellationToken, IGCImplementation? gc = null) + { + gc ??= new SystemGCImplementation(); + try + { + gc.RegisterForFullGCNotification(MaxGenerationThreshold, LargeObjectHeapThreshold); + while (!cancellationToken.IsCancellationRequested) + { + if (gc.WaitForFullGCComplete(TimeSpan.FromSeconds(1)) == GCNotificationStatus.Succeeded) + { + onGarbageCollected?.Invoke(); + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Ignore + } + finally + { + gc.CancelFullGCNotification(); + } + } +} diff --git a/src/Sentry/Internal/HeapDumpOptions.cs b/src/Sentry/Internal/HeapDumpOptions.cs new file mode 100644 index 0000000000..3d68d84d58 --- /dev/null +++ b/src/Sentry/Internal/HeapDumpOptions.cs @@ -0,0 +1,3 @@ +namespace Sentry.Internal; + +internal record HeapDumpOptions(HeapDumpTrigger Trigger, Debouncer Debouncer, SentryLevel Level); diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index accec331cc..b90f4ca603 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -14,6 +14,10 @@ internal class Hub : IHub, IDisposable private readonly SentryOptions _options; private readonly RandomValuesFactory _randomValuesFactory; +#if MEMORY_DUMP_SUPPORTED + private readonly MemoryMonitor? _memoryMonitor; +#endif + private int _isPersistedSessionRecovered; // Internal for testability @@ -60,6 +64,20 @@ internal Hub( PushScope(); } +#if MEMORY_DUMP_SUPPORTED + if (options.HeapDumpOptions is not null) + { + if (_options.DisableFileWrite) + { + _options.LogError("Automatic Heap Dumps cannot be used with file write disabled."); + } + else + { + _memoryMonitor = new MemoryMonitor(options, CaptureHeapDump); + } + } +#endif + foreach (var integration in options.Integrations) { options.LogDebug("Registering integration: '{0}'.", integration.GetType().Name); @@ -483,6 +501,34 @@ private SentryId CaptureEvent(SentryEvent evt, SentryHint? hint, Scope scope) } } +#if MEMORY_DUMP_SUPPORTED + internal void CaptureHeapDump(string dumpFile) + { + if (!IsEnabled) + { + return; + } + + try + { + _options.LogDebug("Capturing heap dump '{0}'", dumpFile); + + var evt = new SentryEvent + { + Message = "Memory threshold exceeded", + Level = _options.HeapDumpOptions?.Level ?? SentryLevel.Warning, + }; + var hint = new SentryHint(_options); + hint.AddAttachment(dumpFile); + CaptureEvent(evt, CurrentScope, hint); + } + catch (Exception e) + { + _options.LogError(e, "Failure to capture heap dump"); + } + } +#endif + public void CaptureUserFeedback(UserFeedback userFeedback) { if (!IsEnabled) @@ -637,6 +683,10 @@ public void Dispose() return; } +#if MEMORY_DUMP_SUPPORTED + _memoryMonitor?.Dispose(); +#endif + try { CurrentClient.FlushAsync(_options.ShutdownTimeout).ConfigureAwait(false).GetAwaiter().GetResult(); diff --git a/src/Sentry/Internal/IGCImplementation.cs b/src/Sentry/Internal/IGCImplementation.cs new file mode 100644 index 0000000000..a148aa2936 --- /dev/null +++ b/src/Sentry/Internal/IGCImplementation.cs @@ -0,0 +1,12 @@ +namespace Sentry.Internal; + +/// +/// This allows us to test the GarbageCollectionMonitor class without a dependency on System.GC, which is static +/// +internal interface IGCImplementation +{ + void RegisterForFullGCNotification(int maxGenerationThreshold, int largeObjectHeapThreshold); + GCNotificationStatus WaitForFullGCComplete(TimeSpan timeout); + void CancelFullGCNotification(); + long TotalAvailableMemoryBytes { get; } +} diff --git a/src/Sentry/Internal/MemoryMonitor.cs b/src/Sentry/Internal/MemoryMonitor.cs new file mode 100644 index 0000000000..03813a5268 --- /dev/null +++ b/src/Sentry/Internal/MemoryMonitor.cs @@ -0,0 +1,195 @@ +/* + * dotnet-gcdump needs .NET 6 or later: + * https://www.nuget.org/packages/dotnet-gcdump#supportedframeworks-body-tab + * + * Also `GC.GetGCMemoryInfo()` is not available in NetFX or NetStandard + */ +#if MEMORY_DUMP_SUPPORTED + +using Sentry.Extensibility; +using Sentry.Internal.Extensions; + +namespace Sentry.Internal; + +internal sealed class MemoryMonitor : IDisposable +{ + private readonly long _totalMemory; + + private readonly SentryOptions _options; + private readonly HeapDumpOptions _dumpOptions; + + private readonly CancellationTokenSource _cancellationTokenSource = new(); + + private readonly Action _onCaptureDump; // Just for testing purposes + private readonly Action _onDumpCollected; + + private Task? _monitorTask; + + public MemoryMonitor(SentryOptions options, Action onDumpCollected, Action? onCaptureDump = null, IGCImplementation? gc = null) + { + if (options.HeapDumpOptions is null) + { + throw new ArgumentException("No heap dump options provided", nameof(options)); + } + + _options = options; + _dumpOptions = options.HeapDumpOptions; + _onDumpCollected = onDumpCollected; + _onCaptureDump = onCaptureDump ?? CaptureMemoryDump; + + gc ??= new SystemGCImplementation(); + _totalMemory = gc.TotalAvailableMemoryBytes; + + // Since we're not awaiting the task, the continuation will happen elsewhere but that's OK - all we care about + // is that any exceptions get logged as soon as possible. + _monitorTask = GarbageCollectionMonitor.Start(CheckMemoryUsage, _cancellationTokenSource.Token, gc) + .ContinueWith( + t => _options.LogError(t.Exception!, "Garbage collection monitor failed"), + TaskContinuationOptions.OnlyOnFaulted // guarantees that the exception is not null + ); + } + + internal void CheckMemoryUsage() + { + var eventTime = DateTimeOffset.UtcNow; + if (!_dumpOptions.Debouncer.CanProcess(eventTime)) + { + return; + } + + var usedMemory = Environment.WorkingSet; + if (!_dumpOptions.Trigger(usedMemory, _totalMemory)) + { + return; + } + + _dumpOptions.Debouncer.RecordOccurence(eventTime); + + var usedMemoryPercentage = ((double)usedMemory / _totalMemory) * 100; + _options.LogDebug("Auto heap dump triggered: Total: {0:N0} bytes, Used: {1:N0} bytes ({2:N2}%)", + _totalMemory, usedMemory, usedMemoryPercentage); + _onCaptureDump(); + } + + public void CaptureMemoryDump() => CaptureMemoryDump(DefaultProcessRunner); + + /// + /// Override used for testing + /// + internal void CaptureMemoryDump(Action dumpProcessRunner) + { + if (_options.DisableFileWrite) + { + _options.LogDebug("File write has been disabled via the options. Unable to create memory dump."); + return; + } + + var dumpFile = TryGetDumpLocation(); + if (dumpFile is null) + { + return; + } + + var command = $"dotnet-gcdump collect -v -p {Environment.ProcessId} -o '{dumpFile}'"; + dumpProcessRunner.Invoke(command); + + if (!_options.FileSystem.FileExists(dumpFile)) + { + // if this happens, hopefully there would be more information in the standard output from the process above + _options.LogError("Unexpected error creating memory dump. Use debug-level to see output of dotnet-gcdump."); + return; + } + + _onDumpCollected(dumpFile); + } + + /// + /// Default process runner for the memory dump command + /// + /// + /// This code hangs the test runners on Windows in CI so must be mocked for testing. + /// + private void DefaultProcessRunner(string command) + { + _options.LogDebug($"Starting process: {command}"); + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "cmd.exe" : "/bin/bash", + Arguments = $"-c \"{command}\"", + RedirectStandardOutput = true, + RedirectStandardError = false, + UseShellExecute = false, + CreateNoWindow = true, + }; + process.Start(); + while (!process.StandardOutput.EndOfStream) + { + if (process.StandardOutput.ReadLine() is { } line) + { + _options.LogDebug($"gcdump: {line}"); + } + } +#if NET8_0_OR_GREATER + process.WaitForExit(TimeSpan.FromSeconds(5)); +#else + process.WaitForExit(5000); +#endif + } + + internal string? TryGetDumpLocation() + { + try + { + var rootPath = _options.CacheDirectoryPath ?? + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var directoryPath = Path.Combine(rootPath, "Sentry", _options.Dsn!.GetHashString()); + var fileSystem = _options.FileSystem; + + if (!fileSystem.CreateDirectory(directoryPath)) + { + _options.LogWarning("Failed to create a directory for memory dump ({0}).", directoryPath); + return null; + } + _options.LogDebug("Created directory for heap dump ({0}).", directoryPath); + + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + var processId = Environment.ProcessId; + var filePath = Path.Combine(directoryPath, $"{timestamp}_{processId}.gcdump"); + if (fileSystem.FileExists(filePath)) + { + _options.LogWarning("Duplicate dump file detected."); + return null; + } + + return filePath; + } + // If there's no write permission or the platform doesn't support this, we handle simply log and bug out + catch (Exception ex) + { + _options.LogError(ex, "Failed to resolve appropriate memory dump location."); + return null; + } + } + + public void Dispose() + { + // Important no exceptions can be thrown from this method as it's called when disposing the Hub + _cancellationTokenSource.Cancel(); + try + { + _monitorTask?.Wait(500); // This should complete very quickly (possibly before we even wait) + } + catch (OperationCanceledException) + { + // Ignore + } + catch (Exception e) + { + _options.LogError(e, "Error waiting for GarbageCollectionMonitor task to complete"); + } + _cancellationTokenSource.Dispose(); + } +} + +#endif diff --git a/src/Sentry/Internal/SystemGCImplementation.cs b/src/Sentry/Internal/SystemGCImplementation.cs new file mode 100644 index 0000000000..6bcaff650a --- /dev/null +++ b/src/Sentry/Internal/SystemGCImplementation.cs @@ -0,0 +1,29 @@ +namespace Sentry.Internal; + +/// +/// When not testing we use `System.GC`. +/// +/// +/// All these methods can throw an exception if concurrent garbage collection has been enabled in the runtime +/// settings for the application. +/// +internal class SystemGCImplementation : IGCImplementation +{ + public void RegisterForFullGCNotification(int maxGenerationThreshold, int largeObjectHeapThreshold) => + GC.RegisterForFullGCNotification(maxGenerationThreshold, largeObjectHeapThreshold); + + public GCNotificationStatus WaitForFullGCComplete(TimeSpan timeout) => +#if NET8_0_OR_GREATER + GC.WaitForFullGCComplete(timeout); +#else + GC.WaitForFullGCComplete((int)timeout.TotalMilliseconds); +#endif + + public void CancelFullGCNotification() => GC.CancelFullGCNotification(); + +#if NET6_0_OR_GREATER + public long TotalAvailableMemoryBytes => GC.GetGCMemoryInfo().TotalAvailableMemoryBytes; +#else + public long TotalAvailableMemoryBytes => throw new PlatformNotSupportedException("This method is only available on .NET 5.0 or later."); +#endif +} diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index 37e9a04c4b..b91e099b48 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -519,6 +519,54 @@ public int MaxQueueItems } } + /* + * dotnet-gcdump needs .NET 6 or later... also `GC.GetGCMemoryInfo()` is not available in NetFX or NetStandard + */ +#if MEMORY_DUMP_SUPPORTED + + /// + /// Configures a heap dump to be captured if the percentage of memory used exceeds a certain threshold. + /// This can be useful to diagnose memory leaks. + /// + /// + /// The memory threshold at which to trigger a heap dump, as a percentage of total available memory. + /// Must be a number between 1 and 99. + /// + /// + /// Limits the frequency at which heap dumps are captured. If no debouncer is set then this defaults to + /// Debouncer.PerApplicationLifetime() + /// + /// Optional parameter controlling the event level associated with heap dumps. + /// Defaults to . + public void EnableHeapDumps(short memoryPercentageThreshold, Debouncer? debouncer = null, SentryLevel level = SentryLevel.Warning) + => EnableHeapDumps(HeapDumpTriggers.MemoryPercentageThreshold(memoryPercentageThreshold), debouncer, level); + + /// + /// + /// Configures Sentry to capture a heap dump based on a trigger function. This can be useful to diagnose memory leaks. + /// + /// + /// Note: This feature requires `dotnet-gcdump` to be installed globally on the machine or container where the heap + /// dumps will be captured. You can install this by running: `dotnet tool install --global dotnet-gcdump` + /// + /// + /// + /// A custom trigger function that accepts the current memory usage and total available memory as arguments and + /// return true to indicate that a heap dump should be captured or false otherwise. + /// + /// + /// Limits the frequency at which heap dumps are captured. If no debouncer is set then this defaults to + /// Debouncer.PerApplicationLifetime() + /// + /// Optional parameter controlling the event level associated with heap dumps. + /// Defaults to . + public void EnableHeapDumps(HeapDumpTrigger trigger, Debouncer? debouncer = null, SentryLevel level = SentryLevel.Warning) + => HeapDumpOptions = new HeapDumpOptions(trigger, debouncer ?? Debouncer.PerApplicationLifetime(), level); + + internal HeapDumpOptions? HeapDumpOptions { get; set; } + +#endif + private int _maxCacheItems = 30; /// diff --git a/test/Sentry.Hangfire.Tests/HangfireTests.cs b/test/Sentry.Hangfire.Tests/HangfireTests.cs index 97fb67333e..0e4601363e 100644 --- a/test/Sentry.Hangfire.Tests/HangfireTests.cs +++ b/test/Sentry.Hangfire.Tests/HangfireTests.cs @@ -10,7 +10,7 @@ public HangfireTests(HangfireFixture hangfireFixture) } [Fact] - public async void ExecuteJobWithAttribute_CapturesCheckInInProgressAndOkWithDuration() + public async Task ExecuteJobWithAttribute_CapturesCheckInInProgressAndOkWithDuration() { var sentryId = SentryId.Create(); _fixture.Hub.CaptureCheckIn(Arg.Any(), Arg.Any()).Returns(sentryId); @@ -29,7 +29,7 @@ public async void ExecuteJobWithAttribute_CapturesCheckInInProgressAndOkWithDura } [Fact] - public async void ExecuteJobWithException_CapturesCheckInInProgressAndErrorWithDuration() + public async Task ExecuteJobWithException_CapturesCheckInInProgressAndErrorWithDuration() { var sentryId = SentryId.Create(); _fixture.Hub.CaptureCheckIn(Arg.Any(), Arg.Any()).Returns(sentryId); @@ -50,7 +50,7 @@ public async void ExecuteJobWithException_CapturesCheckInInProgressAndErrorWithD } [Fact] - public async void ExecuteJobWithoutAttribute_DoesNotCapturesCheckInButLogs() + public async Task ExecuteJobWithoutAttribute_DoesNotCapturesCheckInButLogs() { var sentryId = SentryId.Create(); _fixture.Hub.CaptureCheckIn(Arg.Any(), Arg.Any()).Returns(sentryId); diff --git a/test/Sentry.Testing/BindableTests.cs b/test/Sentry.Testing/BindableTests.cs index 199eb481da..68dd553a36 100644 --- a/test/Sentry.Testing/BindableTests.cs +++ b/test/Sentry.Testing/BindableTests.cs @@ -53,6 +53,7 @@ private static KeyValuePair GetDummyBindableValue(Property not null when propertyType == typeof(bool) => true, not null when propertyType == typeof(string) => $"fake {propertyInfo.Name}", not null when propertyType == typeof(int) => 7, + not null when propertyType == typeof(short) => 7, not null when propertyType == typeof(long) => 7, not null when propertyType == typeof(float) => 0.3f, not null when propertyType == typeof(double) => 0.6, diff --git a/test/Sentry.Testing/SentryOptionsExtensions.cs b/test/Sentry.Testing/SentryOptionsExtensions.cs new file mode 100644 index 0000000000..7e18d2b8ab --- /dev/null +++ b/test/Sentry.Testing/SentryOptionsExtensions.cs @@ -0,0 +1,70 @@ +namespace Sentry.Testing; + +/// +/// We most frequently call IDiagnosticLogger via extension methods on the SentryOptions class, which obscures what +/// we're expecting to receive via mocks when writing tests. This class provides some test extensions that mirror the +/// extensions provided for SentryOptions to make it easier to write tests that match the code they're testing. +/// +public static class SentryOptionsExtensions +{ + private static SentryOptions DidNotReceiveLog(this SentryOptions substitute, SentryLevel level) + { + substitute.DiagnosticLogger.DidNotReceive().Log(level, Arg.Any(), null, Arg.Any()); + return substitute; + } + + private static SentryOptions DidNotReceiveLog(this SentryOptions substitute, SentryLevel level, string message, params object[] args) + { + substitute.DiagnosticLogger.DidNotReceive().Log(level, message, null, args); + return substitute; + } + + private static SentryOptions ReceivedLog(this SentryOptions substitute, SentryLevel level) + { + substitute.DiagnosticLogger.Received().Log(level, Arg.Any(), null, Arg.Any()); + return substitute; + } + + private static SentryOptions ReceivedLog(this SentryOptions substitute, SentryLevel level, string message, params object[] args) + { + substitute.DiagnosticLogger.Received().Log(level, message, null, args); + return substitute; + } + + public static SentryOptions ReceivedLogDebug(this SentryOptions substitute) + => ReceivedLog(substitute, SentryLevel.Debug); + + public static SentryOptions ReceivedLogDebug(this SentryOptions substitute, string message, params object[] args) + => ReceivedLog(substitute, SentryLevel.Debug, message, args); + + public static SentryOptions ReceivedLogInfo(this SentryOptions substitute) + => ReceivedLog(substitute, SentryLevel.Info); + + public static SentryOptions ReceivedLogInfo(this SentryOptions substitute, string message, params object[] args) + => ReceivedLog(substitute, SentryLevel.Info, message, args); + + public static SentryOptions DidNotReceiveReceiveLogInfo(this SentryOptions substitute) + => DidNotReceiveLog(substitute, SentryLevel.Info); + + public static SentryOptions DidNotReceiveReceiveLogInfo(this SentryOptions substitute, string message, params object[] args) + => DidNotReceiveLog(substitute, SentryLevel.Info, message, args); + + public static SentryOptions ReceivedLogWarning(this SentryOptions substitute) + => ReceivedLog(substitute, SentryLevel.Warning); + + public static SentryOptions ReceivedLogWarning(this SentryOptions substitute, string message, params object[] args) + => ReceivedLog(substitute, SentryLevel.Warning, message, args); + + public static SentryOptions ReceivedLogError(this SentryOptions substitute) + => ReceivedLogError(substitute, string.Empty); + + public static SentryOptions ReceivedLogError(this SentryOptions substitute, string message, params object[] args) + => ReceivedLog(substitute, SentryLevel.Error, message, args); + + public static SentryOptions ReceivedLogError(this SentryOptions substitute, Exception exception, string message, + params object[] args) + { + substitute.DiagnosticLogger.Received().Log(SentryLevel.Error, message, exception, Arg.Any()); + return substitute; + } +} diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt index 225135ec55..d23fb2214d 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt @@ -74,6 +74,13 @@ namespace Sentry Managed = 0, ManagedBackgroundThread = 1, } + public class Debouncer + { + public static Sentry.Debouncer PerApplicationLifetime(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + public static Sentry.Debouncer PerDay(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + public static Sentry.Debouncer PerHour(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + public static Sentry.Debouncer PerMinute(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + } [System.Flags] public enum DeduplicateMode { @@ -124,6 +131,7 @@ namespace Sentry { public static void SetTags(this Sentry.IHasTags hasTags, System.Collections.Generic.IEnumerable> tags) { } } + public delegate bool HeapDumpTrigger(long usedMemory, long totalMemory); public static class HintTypes { public const string HttpResponseMessage = "http-response-message"; @@ -720,6 +728,8 @@ namespace Sentry public void DisableDuplicateEventDetection() { } public void DisableUnobservedTaskExceptionCapture() { } public void DisableWinUiUnhandledExceptionIntegration() { } + public void EnableHeapDumps(Sentry.HeapDumpTrigger trigger, Sentry.Debouncer? debouncer = null, Sentry.SentryLevel level = 2) { } + public void EnableHeapDumps(short memoryPercentageThreshold, Sentry.Debouncer? debouncer = null, Sentry.SentryLevel level = 2) { } public System.Collections.Generic.IEnumerable GetAllEventProcessors() { } public System.Collections.Generic.IEnumerable GetAllExceptionProcessors() { } public System.Collections.Generic.IEnumerable GetAllTransactionProcessors() { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet7_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet7_0.verified.txt index 225135ec55..d23fb2214d 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet7_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet7_0.verified.txt @@ -74,6 +74,13 @@ namespace Sentry Managed = 0, ManagedBackgroundThread = 1, } + public class Debouncer + { + public static Sentry.Debouncer PerApplicationLifetime(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + public static Sentry.Debouncer PerDay(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + public static Sentry.Debouncer PerHour(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + public static Sentry.Debouncer PerMinute(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + } [System.Flags] public enum DeduplicateMode { @@ -124,6 +131,7 @@ namespace Sentry { public static void SetTags(this Sentry.IHasTags hasTags, System.Collections.Generic.IEnumerable> tags) { } } + public delegate bool HeapDumpTrigger(long usedMemory, long totalMemory); public static class HintTypes { public const string HttpResponseMessage = "http-response-message"; @@ -720,6 +728,8 @@ namespace Sentry public void DisableDuplicateEventDetection() { } public void DisableUnobservedTaskExceptionCapture() { } public void DisableWinUiUnhandledExceptionIntegration() { } + public void EnableHeapDumps(Sentry.HeapDumpTrigger trigger, Sentry.Debouncer? debouncer = null, Sentry.SentryLevel level = 2) { } + public void EnableHeapDumps(short memoryPercentageThreshold, Sentry.Debouncer? debouncer = null, Sentry.SentryLevel level = 2) { } public System.Collections.Generic.IEnumerable GetAllEventProcessors() { } public System.Collections.Generic.IEnumerable GetAllExceptionProcessors() { } public System.Collections.Generic.IEnumerable GetAllTransactionProcessors() { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 5d13ca7595..a077e63ba5 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -75,6 +75,13 @@ namespace Sentry ManagedBackgroundThread = 1, Native = 2, } + public class Debouncer + { + public static Sentry.Debouncer PerApplicationLifetime(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + public static Sentry.Debouncer PerDay(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + public static Sentry.Debouncer PerHour(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + public static Sentry.Debouncer PerMinute(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + } [System.Flags] public enum DeduplicateMode { @@ -125,6 +132,7 @@ namespace Sentry { public static void SetTags(this Sentry.IHasTags hasTags, System.Collections.Generic.IEnumerable> tags) { } } + public delegate bool HeapDumpTrigger(long usedMemory, long totalMemory); public static class HintTypes { public const string HttpResponseMessage = "http-response-message"; @@ -722,6 +730,8 @@ namespace Sentry public void DisableSystemDiagnosticsMetricsIntegration() { } public void DisableUnobservedTaskExceptionCapture() { } public void DisableWinUiUnhandledExceptionIntegration() { } + public void EnableHeapDumps(Sentry.HeapDumpTrigger trigger, Sentry.Debouncer? debouncer = null, Sentry.SentryLevel level = 2) { } + public void EnableHeapDumps(short memoryPercentageThreshold, Sentry.Debouncer? debouncer = null, Sentry.SentryLevel level = 2) { } public System.Collections.Generic.IEnumerable GetAllEventProcessors() { } public System.Collections.Generic.IEnumerable GetAllExceptionProcessors() { } public System.Collections.Generic.IEnumerable GetAllTransactionProcessors() { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 5d13ca7595..a077e63ba5 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -75,6 +75,13 @@ namespace Sentry ManagedBackgroundThread = 1, Native = 2, } + public class Debouncer + { + public static Sentry.Debouncer PerApplicationLifetime(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + public static Sentry.Debouncer PerDay(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + public static Sentry.Debouncer PerHour(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + public static Sentry.Debouncer PerMinute(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + } [System.Flags] public enum DeduplicateMode { @@ -125,6 +132,7 @@ namespace Sentry { public static void SetTags(this Sentry.IHasTags hasTags, System.Collections.Generic.IEnumerable> tags) { } } + public delegate bool HeapDumpTrigger(long usedMemory, long totalMemory); public static class HintTypes { public const string HttpResponseMessage = "http-response-message"; @@ -722,6 +730,8 @@ namespace Sentry public void DisableSystemDiagnosticsMetricsIntegration() { } public void DisableUnobservedTaskExceptionCapture() { } public void DisableWinUiUnhandledExceptionIntegration() { } + public void EnableHeapDumps(Sentry.HeapDumpTrigger trigger, Sentry.Debouncer? debouncer = null, Sentry.SentryLevel level = 2) { } + public void EnableHeapDumps(short memoryPercentageThreshold, Sentry.Debouncer? debouncer = null, Sentry.SentryLevel level = 2) { } public System.Collections.Generic.IEnumerable GetAllEventProcessors() { } public System.Collections.Generic.IEnumerable GetAllExceptionProcessors() { } public System.Collections.Generic.IEnumerable GetAllTransactionProcessors() { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 5c6899448c..c8a4c7f4c9 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -74,6 +74,13 @@ namespace Sentry Managed = 0, ManagedBackgroundThread = 1, } + public class Debouncer + { + public static Sentry.Debouncer PerApplicationLifetime(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + public static Sentry.Debouncer PerDay(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + public static Sentry.Debouncer PerHour(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + public static Sentry.Debouncer PerMinute(int eventMaximum = 1, System.TimeSpan? cooldown = default) { } + } [System.Flags] public enum DeduplicateMode { @@ -123,6 +130,7 @@ namespace Sentry { public static void SetTags(this Sentry.IHasTags hasTags, System.Collections.Generic.IEnumerable> tags) { } } + public delegate bool HeapDumpTrigger(long usedMemory, long totalMemory); public static class HintTypes { public const string HttpResponseMessage = "http-response-message"; diff --git a/test/Sentry.Tests/DebouncerTests.cs b/test/Sentry.Tests/DebouncerTests.cs new file mode 100644 index 0000000000..39807e9bd4 --- /dev/null +++ b/test/Sentry.Tests/DebouncerTests.cs @@ -0,0 +1,144 @@ +namespace Sentry.Tests; + +public class DebouncerTests +{ + [Fact] + public void PerMinute_InitialisedCorrectly() + { + // Act + var debouncer = Debouncer.PerMinute(); + + // Assert + debouncer.Should().NotBeNull(); + debouncer._intervalType.Should().Be(Debouncer.DebouncerInterval.Minute); + debouncer._eventMaximum.Should().Be(1); + } + + [Fact] + public void PerHour_InitialisedCorrectly() + { + // Act + var debouncer = Debouncer.PerHour(); + + // Assert + debouncer.Should().NotBeNull(); + debouncer._intervalType.Should().Be(Debouncer.DebouncerInterval.Hour); + debouncer._eventMaximum.Should().Be(1); + } + + [Fact] + public void PerDay_InitialisedCorrectly() + { + // Act + var debouncer = Debouncer.PerDay(); + + // Assert + debouncer.Should().NotBeNull(); + debouncer._intervalType.Should().Be(Debouncer.DebouncerInterval.Day); + debouncer._eventMaximum.Should().Be(1); + } + + [Fact] + public void PerApplicationLifetime_InitialisedCorrectly() + { + // Act + var debouncer = Debouncer.PerApplicationLifetime(); + + // Assert + debouncer.Should().NotBeNull(); + debouncer._intervalType.Should().Be(Debouncer.DebouncerInterval.ApplicationLifetime); + debouncer._eventMaximum.Should().Be(1); + } + + [Fact] + public void CanProcess_WithCooldown_RespectsCooldown() + { + // Arrange + var debouncer = Debouncer.PerMinute(10, TimeSpan.FromMinutes(1)); + var initialTime = DateTimeOffset.UtcNow; + debouncer.RecordOccurence(initialTime); + + // Act & Assert + debouncer.CanProcess(initialTime.AddSeconds(59)).Should().BeFalse(); + + // Act & Assert + debouncer.CanProcess(initialTime.AddSeconds(60)).Should().BeTrue(); + + // Act & Assert + debouncer.CanProcess(initialTime.AddSeconds(61)).Should().BeTrue(); + } + + [Fact] + public void CanProcess_NoCooldown_IgnoresCooldown() + { + // Arrange + var debouncer = Debouncer.PerMinute(10); + var initialTime = DateTimeOffset.UtcNow; + debouncer.RecordOccurence(initialTime); + + // Act & Assert + debouncer.CanProcess(initialTime.AddSeconds(59)).Should().BeTrue(); + + // Act & Assert + debouncer.CanProcess(initialTime.AddSeconds(60)).Should().BeTrue(); + + // Act & Assert + debouncer.CanProcess(initialTime.AddSeconds(61)).Should().BeTrue(); + } + + [Fact] + public void CanProcess_RespectsMaximum() + { + // Arrange + var debouncer = Debouncer.PerApplicationLifetime(1); + + // Act + var canProcess = debouncer.CanProcess(); + + // Assert + canProcess.Should().BeTrue(); + + // Act + debouncer.RecordOccurence(); + canProcess = debouncer.CanProcess(); + + // Assert + canProcess.Should().BeFalse(); + } + + [Fact] + public void RecordOccurence_WithinInterval_IncrementsOccurrences() + { + // Arrange + var debouncer = Debouncer.PerMinute(10); + var initialTime = DateTimeOffset.UtcNow; + var secondEventTime = initialTime.AddSeconds(10); + + // Act + debouncer.RecordOccurence(initialTime); + debouncer.RecordOccurence(secondEventTime); + + // Assert + debouncer._occurrences.Should().Be(2); + debouncer._intervalStart.Should().Be(initialTime); + debouncer._lastEvent.Should().Be(secondEventTime); + } + + [Fact] + public void RecordOccurence_AfterInterval_ResetsInterval() + { + // Arrange + var debouncer = Debouncer.PerMinute(10); + var initialTime = DateTimeOffset.UtcNow; + var secondEventTime = initialTime.AddSeconds(70); + + // Act + debouncer.RecordOccurence(initialTime); + debouncer.RecordOccurence(secondEventTime); + + // Assert + debouncer._occurrences.Should().Be(1); + debouncer._intervalStart.Should().Be(secondEventTime); + debouncer._lastEvent.Should().Be(secondEventTime); + } +} diff --git a/test/Sentry.Tests/Internals/GarbageCollectionMonitorTests.cs b/test/Sentry.Tests/Internals/GarbageCollectionMonitorTests.cs new file mode 100644 index 0000000000..afb1abd6de --- /dev/null +++ b/test/Sentry.Tests/Internals/GarbageCollectionMonitorTests.cs @@ -0,0 +1,73 @@ +namespace Sentry.Tests.Internals; + +public class GarbageCollectionMonitorTests +{ + [SkippableFact] + public async Task MonitorGarbageCollection_TaskCancelled_CancelsFullGCNotification() + { + // Arrange + var reset = new ManualResetEventSlim(false); + var gc = Substitute.For(); + gc.When(x => x.RegisterForFullGCNotification(Arg.Any(), Arg.Any())) + .Do(_ => reset.Set()); + gc.WaitForFullGCComplete(Arg.Any()).Returns(GCNotificationStatus.Succeeded); + + var cancellationTokenSource = new CancellationTokenSource(); + + // Act + var task = GarbageCollectionMonitor.Start(() => { }, cancellationTokenSource.Token, gc); + reset.Wait(); // Wait until the task is running + await cancellationTokenSource.CancelAsync(); + await task; + + // Assert + task.Status.Should().Be(TaskStatus.RanToCompletion); + gc.Received(1).CancelFullGCNotification(); + } + + [SkippableFact] + public async Task MonitorGarbageCollection_WaitForFullGCCompleteSucceeds_InvokesOnGarbageCollected() + { + // Arrange + var reset = new ManualResetEventSlim(false); + var onGarbageCollected = Substitute.For(); + var gc = Substitute.For(); + gc.When(x => x.RegisterForFullGCNotification(Arg.Any(), Arg.Any())) + .Do(_ => reset.Set()); + gc.WaitForFullGCComplete(Arg.Any()).Returns(GCNotificationStatus.Succeeded); + + var cancellationTokenSource = new CancellationTokenSource(); + + // Act + var task = GarbageCollectionMonitor.Start(onGarbageCollected, cancellationTokenSource.Token, gc); + reset.Wait(); // Wait until the task is running + await Task.Delay(100); // Give it some time to invoke the callback + + await cancellationTokenSource.CancelAsync(); + await task; + + // Assert + onGarbageCollected.Received(1); + } + + [SkippableFact] + public async Task MonitorGarbageCollection_GCException_Throws() + { + // Arrange + var onGarbageCollected = Substitute.For(); + var gc = Substitute.For(); + gc.When(x => x.RegisterForFullGCNotification(Arg.Any(), Arg.Any())) + .Do(_ => throw new Exception()); + + var cancellationTokenSource = new CancellationTokenSource(); + + // Act + var task = GarbageCollectionMonitor.Start(onGarbageCollected, cancellationTokenSource.Token, gc); + var timeout = Task.Delay(5000, cancellationTokenSource.Token); + await Task.WhenAny([task, timeout]); + + // Assert + task.Status.Should().Be(TaskStatus.Faulted); + task.Exception.Should().NotBeNull(); + } +} diff --git a/test/Sentry.Tests/Internals/MemoryMonitorTests.cs b/test/Sentry.Tests/Internals/MemoryMonitorTests.cs new file mode 100644 index 0000000000..27f3968a1c --- /dev/null +++ b/test/Sentry.Tests/Internals/MemoryMonitorTests.cs @@ -0,0 +1,242 @@ +#if MEMORY_DUMP_SUPPORTED +namespace Sentry.Tests.Internals; + +public class MemoryMonitorTests +{ + private HeapDumpTrigger NeverTrigger { get; } = (_, _) => false; + private HeapDumpTrigger AlwaysTrigger { get; } = (_, _) => true; + + private class Fixture + { + private IGCImplementation GCImplementation { get; set; } + + public SentryOptions Options { get; set; } = new() + { + Dsn = ValidDsn, + Debug = true, + DiagnosticLogger = Substitute.For() + }; + + public Action OnDumpCollected { get; set; } = _ => { }; + + public Action OnCaptureDump { get; set; } = null; + + private const short ThresholdPercentage = 5; + + public static IGCImplementation MockGCImplementation() + { + var gc = Substitute.For(); + gc.TotalAvailableMemoryBytes.Returns(1024 * 1024 * 1024); + return gc; + } + + public MemoryMonitor GetSut() + { + Options.DiagnosticLogger?.IsEnabled(Arg.Any()).Returns(true); + return new MemoryMonitor(Options, OnDumpCollected, OnCaptureDump, GCImplementation ?? MockGCImplementation()); + } + } + + private readonly Fixture _fixture = new(); + + [Fact] + public void Constructor_NoHeapdumpsConfigured_Throws() + { + var doNothing = new Action(() => { }); + + // Arrange + _fixture.Options.HeapDumpOptions = null; + + // Act + MemoryMonitor sut = null; + try + { + Assert.Throws(() => sut = new MemoryMonitor( + _fixture.Options, _fixture.OnDumpCollected, doNothing, Fixture.MockGCImplementation() + )); + } + finally + { + sut?.Dispose(); + } + } + + [Fact] + public void CheckMemoryUsage_Debounced_DoesNotCapture() + { + // Arrange + _fixture.Options.EnableHeapDumps( + AlwaysTrigger, + Debouncer.PerApplicationLifetime(0) // always debounce + ); + var dumpCaptured = false; + _fixture.OnCaptureDump = () => dumpCaptured = true; + using var sut = _fixture.GetSut(); + + // Act + sut.CheckMemoryUsage(); + + // Assert + dumpCaptured.Should().BeFalse(); + } + + [Fact] + public void CheckMemoryUsage_NotTriggered_DoesNotCapture() + { + // Arrange + _fixture.Options.EnableHeapDumps( + NeverTrigger, + Debouncer.PerApplicationLifetime(int.MaxValue) // never debounce + ); + var dumpCaptured = false; + _fixture.OnCaptureDump = () => dumpCaptured = true; + using var sut = _fixture.GetSut(); + + // Act + sut.CheckMemoryUsage(); + + // Assert + dumpCaptured.Should().BeFalse(); + } + + [Fact] + public void CheckMemoryUsage_TriggeredNotDebounced_Captures() + { + // Arrange + _fixture.Options.EnableHeapDumps( + AlwaysTrigger, + Debouncer.PerApplicationLifetime(int.MaxValue) // never debounce + ); + var dumpCaptured = false; + _fixture.OnCaptureDump = () => dumpCaptured = true; + using var sut = _fixture.GetSut(); + + // Act + sut.CheckMemoryUsage(); + + // Assert + dumpCaptured.Should().BeTrue(); + } + + [Fact] + public void CaptureMemoryDump_DisableFileWrite_DoesNotCapture() + { + // Arrange + _fixture.Options.EnableHeapDumps(AlwaysTrigger); + _fixture.Options.DisableFileWrite = true; + using var sut = _fixture.GetSut(); + var processRunner = Substitute.For>(); + + // Act + sut.CaptureMemoryDump(processRunner); + + // Assert + _fixture.Options.ReceivedLogDebug("File write has been disabled via the options. Unable to create memory dump."); + processRunner.DidNotReceive().Invoke(Arg.Any()); + } + + [Fact] + public void CaptureMemoryDump_UnresolvedDumpLocation_DoesNotCapture() + { + // Arrange + _fixture.Options.EnableHeapDumps(AlwaysTrigger); + _fixture.Options.FileSystem = Substitute.For(); + _fixture.Options.FileSystem.CreateDirectory(Arg.Any()).Returns(false); + using var sut = _fixture.GetSut(); + var processRunner = Substitute.For>(); + + // Act + sut.CaptureMemoryDump(processRunner); + + // Assert + processRunner.DidNotReceive().Invoke(Arg.Any()); + } + + [Fact] + public void CaptureMemoryDump_CapturesDump() + { + // Arrange + _fixture.Options.EnableHeapDumps(AlwaysTrigger); + _fixture.Options.FileSystem = new FakeFileSystem(); + using var sut = _fixture.GetSut(); + var processRunner = Substitute.For>(); + + // Act + sut.CaptureMemoryDump(processRunner); + + // Assert + processRunner.Received(1).Invoke(Arg.Any()); + } + + [Fact] + public void TryGetDumpLocation_DirectoryCreationFails_ReturnsNull() + { + // Arrange + _fixture.Options.EnableHeapDumps(AlwaysTrigger); + _fixture.Options.FileSystem = Substitute.For(); + _fixture.Options.FileSystem.CreateDirectory(Arg.Any()).Returns(false); + using var sut = _fixture.GetSut(); + + // Act + var result = sut.TryGetDumpLocation(); + + // Assert + result.Should().BeNull(); + _fixture.Options.FileSystem.Received().CreateDirectory(Arg.Any()); + _fixture.Options.FileSystem.DidNotReceive().FileExists(Arg.Any()); + _fixture.Options.ReceivedLogWarning("Failed to create a directory for memory dump ({0}).", Arg.Any()); + } + + [Fact] + public void TryGetDumpLocation_DumpFileExists_ReturnsNull() + { + // Arrange + _fixture.Options.EnableHeapDumps(AlwaysTrigger); + _fixture.Options.FileSystem = Substitute.For(); + _fixture.Options.FileSystem.CreateDirectory(Arg.Any()).Returns(true); + _fixture.Options.FileSystem.FileExists(Arg.Any()).Returns(true); + using var sut = _fixture.GetSut(); + + // Act + var result = sut.TryGetDumpLocation(); + + // Assert + result.Should().BeNull(); + _fixture.Options.FileSystem.Received().CreateDirectory(Arg.Any()); + _fixture.Options.FileSystem.Received().FileExists(Arg.Any()); + _fixture.Options.ReceivedLogWarning("Duplicate dump file detected."); + } + + [Fact] + public void TryGetDumpLocation_Exception_LogsError() + { + // Arrange + _fixture.Options.EnableHeapDumps(AlwaysTrigger); + _fixture.Options.FileSystem = Substitute.For(); + _fixture.Options.FileSystem.CreateDirectory(Arg.Any()).Throws(_ => new Exception()); + using var sut = _fixture.GetSut(); + + // Act + var result = sut.TryGetDumpLocation(); + + // Assert + result.Should().BeNull(); + _fixture.Options.ReceivedLogError(Arg.Any(), "Failed to resolve appropriate memory dump location."); + } + + [Fact] + public void TryGetDumpLocation_ReturnsFilePath() + { + // Arrange + _fixture.Options.EnableHeapDumps(AlwaysTrigger); + _fixture.Options.FileSystem = new FakeFileSystem(); + using var sut = _fixture.GetSut(); + + // Act + var result = sut.TryGetDumpLocation(); + + // Assert + result.Should().NotBeNull(); + } +} +#endif diff --git a/test/Sentry.Tests/Internals/SentryStackTraceFactoryTests.cs b/test/Sentry.Tests/Internals/SentryStackTraceFactoryTests.cs index 1897b635f1..10643e6bc2 100644 --- a/test/Sentry.Tests/Internals/SentryStackTraceFactoryTests.cs +++ b/test/Sentry.Tests/Internals/SentryStackTraceFactoryTests.cs @@ -51,8 +51,8 @@ public void Create_NoExceptionAndAttachStackTraceOptionOnWithOriginalMode_Curren // See https://github.com/getsentry/sentry-dotnet/pull/2732#discussion_r1371006441 [Theory] [InlineData(StackTraceMode.Original, "AsyncWithWait_StackTrace { }")] - [InlineData(StackTraceMode.Enhanced, "void SentryStackTraceFactoryTests.AsyncWithWait_StackTrace(StackTraceMode mode, string method)+() => { }")] - public async void AsyncWithWait_StackTrace(StackTraceMode mode, string method) + [InlineData(StackTraceMode.Enhanced, "Task SentryStackTraceFactoryTests.AsyncWithWait_StackTrace(StackTraceMode mode, string method)+() => { }")] + public async Task AsyncWithWait_StackTrace(StackTraceMode mode, string method) { _fixture.SentryOptions.StackTraceMode = mode; _fixture.SentryOptions.AttachStacktrace = true; diff --git a/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs b/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs index f165b78c96..93f28b7379 100644 --- a/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs +++ b/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs @@ -568,7 +568,7 @@ public void Serialization_EnvelopeWithThrowingItem_DoesntThrow() } [Fact] - public async void AsyncSerialization_EnvelopeWithThrowingItem_DoesntThrow() + public async Task AsyncSerialization_EnvelopeWithThrowingItem_DoesntThrow() { // Arrange using var envelope = new Envelope(