Skip to content

Commit d1e5efc

Browse files
bruno-garciajamescrosswellgetsentry-bot
authored
feat: blocking detection (#2709)
* feat: blocking detection * half assed stuff from the weekend * verify * ref * binding * context * wip * wip * Fixed compiler errors * Update CHANGELOG.md * Format code * Reintroducing Bruno's changes with tests * Reintroduced changes to MainSentryEventProcessor * Reintroduced the core blocking capability * Update SentryStackTraceFactory.cs * Update HubTests.CaptureEvent_ActiveTransaction_UnhandledExceptionTransactionEndedAsCrashed.Net4_8.verified.txt * Added blocking detection suppression * Format code * Reverted unintentional changes to ApiDefinitions * Fixed stack traces for blocking calls * Update BlockingMonitor.cs * Some basic tests for BlockingMonitor * Added code to ensure blocking events only get sent once per day * Format code * Added System.Diagnostics.Metrics for blocking calls so users can know the number of blocking calls for a particular code location * removed throttling mechanism * Update CHANGELOG.md * merge issues * Refactored to improve testability * Format code * Moved IBlockingMonitor to a separate file * Applied review feedback --------- Co-authored-by: James Crosswell <[email protected]> Co-authored-by: Sentry Github Bot <[email protected]> Co-authored-by: James Crosswell <[email protected]>
1 parent 711e61e commit d1e5efc

File tree

39 files changed

+1066
-254
lines changed

39 files changed

+1066
-254
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
### Features
66

7+
- ASP.NET Core: Blocking call detection. An event with the stack trace of the blocking call will be captured as event. ([#2709](https://github.com/getsentry/sentry-dotnet/pull/2709))
8+
- IMPORTANT: Verify this in test/staging before prod! Blocking calls in hot paths could create a lot of events for your Sentry project.
9+
- Opt-in via `options.CaptureBlockingCalls = true`
10+
- Disabled for specific code blocks with `using (new SuppressBlockingDetection())`
11+
- Doesn't detect everything. See original [Caveats described by Ben Adams](https://github.com/benaadams/Ben.BlockingDetector?tab=readme-ov-file#caveats).
712
- Added Crons support via `SentrySdk.CaptureCheckIn` and an integration with Hangfire ([#3128](https://github.com/getsentry/sentry-dotnet/pull/3128))
813
- Common tags set automatically for metrics and metrics summaries are attached to Spans ([#3191](https://github.com/getsentry/sentry-dotnet/pull/3191))
914

samples/Sentry.Samples.AspNetCore.Mvc/Controllers/HomeController.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Data;
22
using System.Diagnostics;
33
using Microsoft.AspNetCore.Mvc;
4+
using Sentry.Ben.BlockingDetector;
45
using Sentry.Samples.AspNetCore.Mvc.Models;
56

67
namespace Samples.AspNetCore.Mvc.Controllers;
@@ -12,6 +13,53 @@ public IActionResult Index()
1213
return View();
1314
}
1415

16+
// GET /home/block/true or /home/block/false to observe events
17+
[HttpGet("[controller]/block/{block?}")]
18+
public async Task<string> Block([FromRoute] bool block)
19+
{
20+
if (block)
21+
{
22+
logger.LogInformation("\ud83d\ude31 Calling a blocking API on an async method \ud83d\ude31");
23+
24+
// This will result in an event in Sentry
25+
Task.Delay(10).Wait(); // This is a blocking call. Same with '.Result'
26+
}
27+
else
28+
{
29+
logger.LogInformation("\ud83d\ude31 No blocking call made \ud83d\ude31");
30+
31+
// Non-blocking, no event captured
32+
await Task.Delay(10);
33+
}
34+
35+
return "Was blocking? " + block;
36+
}
37+
38+
// GET /home/suppress/true or /home/suppress/false to observe events
39+
[HttpGet("[controller]/suppress/{suppress?}")]
40+
public async Task<string> Suppress([FromRoute] bool suppress)
41+
{
42+
if (suppress)
43+
{
44+
logger.LogInformation("Blocking suppression enabled");
45+
using (new SuppressBlockingDetection())
46+
{
47+
Task.Delay(10).Wait(); // This is blocking but won't trigger an event, due to suppression
48+
}
49+
logger.LogInformation("Blocking suppression disabled");
50+
}
51+
else
52+
{
53+
logger.LogInformation("\ud83d\ude31 Unsuppressed blocking call on an async method \ud83d\ude31");
54+
Task.Delay(10).Wait(); // This is blocking but won't trigger an event, due to suppression
55+
}
56+
57+
// Non-blocking, no event captured
58+
await Task.Delay(10);
59+
60+
return "Was suppressed? " + suppress;
61+
}
62+
1563
// Example: An exception that goes unhandled by the app will be captured by Sentry:
1664
[HttpPost]
1765
public async Task PostIndex(string? @params)

samples/Sentry.Samples.AspNetCore.Mvc/Program.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
// Example: Disabling support to compressed responses:
2626
options.DecompressionMethods = DecompressionMethods.None;
2727

28+
// Call GET /home/block/true to see this in action
29+
options.CaptureBlockingCalls = true;
30+
2831
options.MaxQueueItems = 100;
2932
options.ShutdownTimeout = TimeSpan.FromSeconds(5);
3033

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Sample usages of the Sentry SDK for ASP.NET Core on an MVC app
2+
3+
Start by changing the DSN in `appsettings.json`, `Sentry:Dsn` property with your own.
4+
No DSN yet? Get one for free at https://sentry.io/ to give this sample a run.
5+
6+
Blocking detection:
7+
* It's turned on via option `CaptureBlockingCalls`
8+
* In the `HomeController` there's an action that causes a blocking call on an async method.
9+
* You can trigger it with:
10+
* `GET http://localhost:5001/home/block/true`
11+
12+
Results `Was blocking? True` and an event captured in Sentry.

samples/Sentry.Samples.AspNetCore.Mvc/Sentry.Samples.AspNetCore.Mvc.csproj

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -11,55 +11,4 @@
1111
<ProjectReference Include="..\..\src\Sentry.AspNetCore\Sentry.AspNetCore.csproj" />
1212
</ItemGroup>
1313

14-
<ItemGroup>
15-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.css" />
16-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.css.map" />
17-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.min.css" />
18-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.min.css.map" />
19-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.rtl.css" />
20-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.rtl.css.map" />
21-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.rtl.min.css" />
22-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.rtl.min.css.map" />
23-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.css" />
24-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.css.map" />
25-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.min.css" />
26-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.min.css.map" />
27-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.rtl.css" />
28-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.rtl.css.map" />
29-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.rtl.min.css" />
30-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.rtl.min.css.map" />
31-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.css" />
32-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.css.map" />
33-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.min.css" />
34-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.min.css.map" />
35-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.rtl.css" />
36-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.rtl.css.map" />
37-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.rtl.min.css" />
38-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.rtl.min.css.map" />
39-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.css" />
40-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.css.map" />
41-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.min.css" />
42-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.min.css.map" />
43-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.rtl.css" />
44-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.rtl.css.map" />
45-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.rtl.min.css" />
46-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.rtl.min.css.map" />
47-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.bundle.js" />
48-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.bundle.js.map" />
49-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.bundle.min.js" />
50-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.bundle.min.js.map" />
51-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.esm.js" />
52-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.esm.js.map" />
53-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.esm.min.js" />
54-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.esm.min.js.map" />
55-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.js" />
56-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.js.map" />
57-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.min.js" />
58-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.min.js.map" />
59-
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\LICENSE" />
60-
<_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation-unobtrusive\jquery.validate.unobtrusive.js" />
61-
<_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation-unobtrusive\jquery.validate.unobtrusive.min.js" />
62-
<_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation-unobtrusive\LICENSE.txt" />
63-
</ItemGroup>
64-
6514
</Project>

src/Sentry.AspNetCore/BindableSentryAspNetCoreOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ internal class BindableSentryAspNetCoreOptions : BindableSentryLoggingOptions
1616
public bool? FlushBeforeRequestCompleted { get; set; }
1717
public bool? AdjustStandardEnvironmentNameCasing { get; set; }
1818
public bool? AutoRegisterTracing { get; set; }
19+
public bool? CaptureBlockingCalls { get; set; }
1920

2021
public void ApplyTo(SentryAspNetCoreOptions options)
2122
{
@@ -26,5 +27,6 @@ public void ApplyTo(SentryAspNetCoreOptions options)
2627
options.FlushBeforeRequestCompleted = FlushBeforeRequestCompleted ?? options.FlushBeforeRequestCompleted;
2728
options.AdjustStandardEnvironmentNameCasing = AdjustStandardEnvironmentNameCasing ?? options.AdjustStandardEnvironmentNameCasing;
2829
options.AutoRegisterTracing = AutoRegisterTracing ?? options.AutoRegisterTracing;
30+
options.CaptureBlockingCalls = CaptureBlockingCalls ?? options.CaptureBlockingCalls;
2931
}
3032
}

src/Sentry.AspNetCore/SentryAspNetCoreOptions.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ public class SentryAspNetCoreOptions : SentryLoggingOptions
4242
/// </summary>
4343
internal bool FlushBeforeRequestCompleted { get; set; }
4444

45+
/// <summary>
46+
/// Will capture an event if a blocking call is detected.
47+
/// </summary>
48+
/// <remarks>
49+
/// Get a Sentry event for a sync over async call such as (await SomeTask).Result
50+
/// Note that when ConfigureAwait(false) is used, blocking won't be detected.
51+
/// Additionally, detects blocking calls over CLR waits such as lock, ReaderWriterLock, etc.
52+
/// Blocking calls through system calls are also not captured.
53+
/// </remarks>
54+
/// <seealso href="https://github.com/getsentry/Ben.BlockingDetector/"/>
55+
public bool CaptureBlockingCalls { get; set; }
56+
4557
/// <summary>
4658
/// The strategy to define the name of a transaction based on the <see cref="HttpContext"/>.
4759
/// </summary>

src/Sentry.AspNetCore/SentryMiddleware.cs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Microsoft.Extensions.Logging;
44
using Microsoft.Extensions.Options;
55
using Sentry.AspNetCore.Extensions;
6+
using Sentry.Ben.BlockingDetector;
67
using Sentry.Extensibility;
78
using Sentry.Internal;
89
using Sentry.Reflection;
@@ -32,6 +33,11 @@ internal static readonly SdkVersion NameAndVersion
3233

3334
private static readonly string ProtocolPackageName = "nuget:" + NameAndVersion.Name;
3435

36+
// Ben.BlockingDetector
37+
private readonly BlockingMonitor? _monitor;
38+
private readonly DetectBlockingSynchronizationContext? _detectBlockingSyncCtx;
39+
private readonly TaskBlockingListener? _listener;
40+
3541
/// <summary>
3642
/// Initializes a new instance of the <see cref="SentryMiddleware"/> class.
3743
/// </summary>
@@ -63,6 +69,13 @@ public SentryMiddleware(
6369
_eventExceptionProcessors = eventExceptionProcessors;
6470
_eventProcessors = eventProcessors;
6571
_transactionProcessors = transactionProcessors;
72+
73+
if (_options.CaptureBlockingCalls)
74+
{
75+
_monitor = new BlockingMonitor(_getHub, _options);
76+
_detectBlockingSyncCtx = new DetectBlockingSynchronizationContext(_monitor);
77+
_listener = new TaskBlockingListener(_monitor);
78+
}
6679
}
6780

6881
/// <summary>
@@ -132,7 +145,24 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next)
132145
try
133146
{
134147
var originalMethod = context.Request.Method;
135-
await next(context).ConfigureAwait(false);
148+
if (_options.CaptureBlockingCalls && _monitor is not null)
149+
{
150+
var syncCtx = SynchronizationContext.Current;
151+
SynchronizationContext.SetSynchronizationContext(syncCtx == null ? _detectBlockingSyncCtx : new DetectBlockingSynchronizationContext(_monitor, syncCtx));
152+
try
153+
{
154+
// For detection to work we need ConfigureAwait=true
155+
await next(context).ConfigureAwait(true);
156+
}
157+
finally
158+
{
159+
SynchronizationContext.SetSynchronizationContext(syncCtx);
160+
}
161+
}
162+
else
163+
{
164+
await next(context).ConfigureAwait(false);
165+
}
136166
if (_options.Instrumenter == Instrumenter.OpenTelemetry && Activity.Current is { } activity)
137167
{
138168
// The middleware pipeline finishes up before the Otel Activity.OnEnd callback is invoked so we need
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
using Sentry.Internal;
2+
using Sentry.Protocol;
3+
4+
// Namespace starting with Sentry makes sure the SDK cuts frames off before reporting
5+
namespace Sentry.Ben.BlockingDetector
6+
{
7+
internal class BlockingMonitor : IBlockingMonitor
8+
{
9+
private readonly Func<IHub> _getHub;
10+
private readonly SentryOptions _options;
11+
internal readonly IRecursionTracker _recursionTracker;
12+
13+
public BlockingMonitor(Func<IHub> getHub, SentryOptions options)
14+
: this(getHub, options, new StaticRecursionTracker())
15+
{
16+
}
17+
18+
internal BlockingMonitor(Func<IHub> getHub, SentryOptions options, IRecursionTracker recursionTracker)
19+
{
20+
_getHub = getHub;
21+
_options = options;
22+
_recursionTracker = recursionTracker;
23+
}
24+
25+
private static bool ShouldSkipFrame(string? frameInfo) =>
26+
frameInfo?.StartsWith("Sentry.Ben") == true
27+
// Skip frames relating to the TaskBlockingListener
28+
|| frameInfo?.StartsWith("System.Diagnostics") == true
29+
// Skip frames relating to the async state machine
30+
|| frameInfo?.StartsWith("System.Threading") == true;
31+
32+
public void BlockingStart(DetectionSource detectionSource)
33+
{
34+
// From Stephen Cleary:
35+
// "The default SynchronizationContext queues its asynchronous delegates to the ThreadPool but executes its
36+
// synchronous delegates directly on the calling thread."
37+
//
38+
// Implicitly then, if we're not on a ThreadPool thread, we're not in an async context.
39+
if (!Thread.CurrentThread.IsThreadPoolThread)
40+
{
41+
return;
42+
}
43+
44+
_recursionTracker.Recurse();
45+
46+
try
47+
{
48+
if (!_recursionTracker.IsFirstRecursion())
49+
{
50+
return;
51+
}
52+
53+
var stackTrace = DebugStackTrace.Create(
54+
_options,
55+
new StackTrace(true),
56+
true,
57+
ShouldSkipFrame
58+
);
59+
var evt = new SentryEvent
60+
{
61+
Level = SentryLevel.Warning,
62+
Message =
63+
"Blocking method has been invoked and blocked, this can lead to ThreadPool starvation. Learn more about it: " +
64+
"https://learn.microsoft.com/en-us/aspnet/core/fundamentals/best-practices#avoid-blocking-calls ",
65+
SentryExceptions = new[]
66+
{
67+
new SentryException
68+
{
69+
ThreadId = Environment.CurrentManagedThreadId,
70+
Mechanism = new Mechanism
71+
{
72+
Type = "BlockingCallDetector",
73+
Handled = false,
74+
Description = "Blocking calls can cause ThreadPool starvation.",
75+
Source = detectionSource.ToString()
76+
},
77+
Type = "Blocking call detected",
78+
Stacktrace = stackTrace,
79+
}
80+
},
81+
};
82+
83+
_getHub().CaptureEvent(evt);
84+
}
85+
catch
86+
{
87+
// ignored
88+
}
89+
}
90+
91+
public void BlockingEnd()
92+
{
93+
if (!Thread.CurrentThread.IsThreadPoolThread)
94+
{
95+
return;
96+
}
97+
98+
_recursionTracker.Backtrack();
99+
}
100+
}
101+
102+
internal enum DetectionSource
103+
{
104+
SynchronizationContext,
105+
EventListener
106+
}
107+
}

0 commit comments

Comments
 (0)