Skip to content

Commit 2a8813a

Browse files
Rework handling of AggregateException to align with Sentry Exception Groups (#2287)
1 parent 3b2dcc2 commit 2a8813a

File tree

33 files changed

+323
-151
lines changed

33 files changed

+323
-151
lines changed

Diff for: CHANGELOG.md

+10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Features
6+
7+
- .NET SDK changes for exception groups ([#2287](https://github.com/getsentry/sentry-dotnet/pull/2287))
8+
- This changes how `AggregateException` is handled. Instead of filtering them out client-side, the SDK marks them as an "exception group",
9+
and adds includes data that represents the hierarchical structure of inner exceptions. Sentry now recognizes this server-side,
10+
improving the accuracy of the issue detail page.
11+
- Accordingly, the `KeepAggregateException` option is now obsolete and does nothing. Please remove any usages of `KeepAggregateException`.
12+
313
## 3.32.0
414

515
### Features

Diff for: src/Sentry/Internal/Extensions/MiscExtensions.cs

+15
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,19 @@ public static void Add<TKey, TValue>(
8282
TKey key,
8383
TValue value) =>
8484
collection.Add(new KeyValuePair<TKey, TValue>(key, value));
85+
86+
internal static string GetRawMessage(this AggregateException exception)
87+
{
88+
var message = exception.Message;
89+
if (exception.InnerException is { } inner)
90+
{
91+
var i = message.IndexOf($" ({inner.Message})", StringComparison.Ordinal);
92+
if (i > 0)
93+
{
94+
return message[..i];
95+
}
96+
}
97+
98+
return message;
99+
}
85100
}

Diff for: src/Sentry/Internal/MainExceptionProcessor.cs

+76-28
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,68 @@ public void Process(Exception exception, SentryEvent sentryEvent)
3030
sentryEvent.SentryExceptions = sentryExceptions;
3131
}
3232

33+
// Sentry exceptions are sorted oldest to newest.
34+
// See https://develop.sentry.dev/sdk/event-payloads/exception
35+
internal IReadOnlyList<SentryException> CreateSentryExceptions(Exception exception)
36+
{
37+
var exceptions = WalkExceptions(exception).Reverse().ToList();
38+
39+
// In the case of only one exception, ExceptionId and ParentId are useless.
40+
if (exceptions.Count == 1 && exceptions[0].Mechanism is { } mechanism)
41+
{
42+
mechanism.ExceptionId = null;
43+
mechanism.ParentId = null;
44+
if (mechanism.IsDefaultOrEmpty())
45+
{
46+
// No need to convey an empty mechanism.
47+
exceptions[0].Mechanism = null;
48+
}
49+
}
50+
51+
return exceptions;
52+
}
53+
54+
private class Counter
55+
{
56+
private int _value;
57+
58+
public int GetNextValue() => _value++;
59+
}
60+
61+
private IEnumerable<SentryException> WalkExceptions(Exception exception) =>
62+
WalkExceptions(exception, new Counter(), null, null);
63+
64+
private IEnumerable<SentryException> WalkExceptions(Exception exception, Counter counter, int? parentId, string? source)
65+
{
66+
var ex = exception;
67+
while (ex is not null)
68+
{
69+
var id = counter.GetNextValue();
70+
71+
yield return BuildSentryException(ex, id, parentId, source);
72+
73+
if (ex is AggregateException aex)
74+
{
75+
for (var i = 0; i < aex.InnerExceptions.Count; i++)
76+
{
77+
ex = aex.InnerExceptions[i];
78+
source = $"{nameof(AggregateException.InnerExceptions)}[{i}]";
79+
var sentryExceptions = WalkExceptions(ex, counter, id, source);
80+
foreach (var sentryException in sentryExceptions)
81+
{
82+
yield return sentryException;
83+
}
84+
}
85+
86+
break;
87+
}
88+
89+
ex = ex.InnerException;
90+
parentId = id;
91+
source = nameof(AggregateException.InnerException);
92+
}
93+
}
94+
3395
private static void MoveExceptionDataToEvent(SentryEvent sentryEvent, IEnumerable<SentryException> sentryExceptions)
3496
{
3597
var keysToRemove = new List<string>();
@@ -77,41 +139,17 @@ value is string stringValue &&
77139
}
78140
}
79141

80-
internal List<SentryException> CreateSentryExceptions(Exception exception)
81-
{
82-
var exceptions = exception
83-
.EnumerateChainedExceptions(_options)
84-
.Select(BuildSentryException)
85-
.ToList();
86-
87-
// If we've filtered out the aggregate exception, we'll need to copy over details from it.
88-
if (exception is AggregateException && !_options.KeepAggregateException)
89-
{
90-
var original = BuildSentryException(exception);
91-
92-
// Exceptions are sent from oldest to newest, so the details belong on the LAST exception.
93-
var last = exceptions.Last();
94-
last.Mechanism = original.Mechanism;
95-
96-
// In some cases the stack trace is already positioned on the inner exception.
97-
// Only copy it over when it is missing.
98-
last.Stacktrace ??= original.Stacktrace;
99-
}
100-
101-
return exceptions;
102-
}
103-
104-
private SentryException BuildSentryException(Exception exception)
142+
private SentryException BuildSentryException(Exception exception, int id, int? parentId, string? source)
105143
{
106144
var sentryEx = new SentryException
107145
{
108146
Type = exception.GetType().FullName,
109147
Module = exception.GetType().Assembly.FullName,
110-
Value = exception.Message,
148+
Value = exception is AggregateException agg ? agg.GetRawMessage() : exception.Message,
111149
ThreadId = Environment.CurrentManagedThreadId
112150
};
113151

114-
var mechanism = GetMechanism(exception);
152+
var mechanism = GetMechanism(exception, id, parentId, source);
115153
if (!mechanism.IsDefaultOrEmpty())
116154
{
117155
sentryEx.Mechanism = mechanism;
@@ -121,7 +159,7 @@ private SentryException BuildSentryException(Exception exception)
121159
return sentryEx;
122160
}
123161

124-
private static Mechanism GetMechanism(Exception exception)
162+
private static Mechanism GetMechanism(Exception exception, int id, int? parentId, string? source)
125163
{
126164
var mechanism = new Mechanism();
127165

@@ -167,6 +205,16 @@ private static Mechanism GetMechanism(Exception exception)
167205
mechanism.Data[key] = exception.Data[key]!;
168206
}
169207

208+
mechanism.ExceptionId = id;
209+
mechanism.ParentId = parentId;
210+
mechanism.Source = source;
211+
mechanism.IsExceptionGroup = exception is AggregateException;
212+
213+
if (source != null)
214+
{
215+
mechanism.Type = "chained";
216+
}
217+
170218
return mechanism;
171219
}
172220
}

Diff for: src/Sentry/Protocol/Mechanism.cs

+51
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ public string Type
5656
/// </summary>
5757
public string? Description { get; set; }
5858

59+
/// <summary>
60+
/// An optional value to explain the source of the exception.
61+
/// </summary>
62+
/// <remarks>
63+
/// For chained exceptions, this should be the property name where the exception was retrieved from its parent
64+
/// exception. In .NET, either &quot;<see cref="Exception.InnerException"/>&quot; or <c>&quot;InnerExceptions[i]&quot;</c>
65+
/// (where <c>i</c> is replaced with the numeric index within <see cref="AggregateException.InnerExceptions"/>).
66+
/// </remarks>
67+
public string? Source { get; set; }
68+
5969
/// <summary>
6070
/// Optional fully qualified URL to an online help resource, possible interpolated with error parameters.
6171
/// </summary>
@@ -71,6 +81,31 @@ public string Type
7181
/// </summary>
7282
public bool Synthetic { get; set; }
7383

84+
/// <summary>
85+
/// Whether the exception represents an exception group.
86+
/// In .NET, an <see cref="AggregateException"/>.
87+
/// </summary>
88+
public bool IsExceptionGroup { get; set; }
89+
90+
/// <summary>
91+
/// A numeric identifier assigned to the exception by the SDK.
92+
/// </summary>
93+
/// <remarks>
94+
/// The SDK should assign a different ID to each exception in an event, starting with the root exception as 0,
95+
/// and incrementing thereafter. This ID can be used with <see cref="ParentId"/> to reconstruct the logical
96+
/// structure of an exception group. When <c>null</c>, Sentry will assume that all exceptions in an event are
97+
/// in a single chain.
98+
/// </remarks>
99+
public int? ExceptionId { get; set; }
100+
101+
/// <summary>
102+
/// The parent exception's identifier, or <c>null</c> for the root exception.
103+
/// </summary>
104+
/// <remarks>
105+
/// This ID can be used with <see cref="ExceptionId"/> to reconstruct the logical structure of an exception group.
106+
/// </remarks>
107+
public int? ParentId { get; set; }
108+
74109
/// <summary>
75110
/// Optional information from the operating system or runtime on the exception mechanism.
76111
/// </summary>
@@ -95,9 +130,13 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger)
95130

96131
writer.WriteString("type", Type);
97132
writer.WriteStringIfNotWhiteSpace("description", Description);
133+
writer.WriteStringIfNotWhiteSpace("source", Source);
98134
writer.WriteStringIfNotWhiteSpace("help_link", HelpLink);
99135
writer.WriteBooleanIfNotNull("handled", Handled);
100136
writer.WriteBooleanIfTrue("synthetic", Synthetic);
137+
writer.WriteBooleanIfTrue("is_exception_group", IsExceptionGroup);
138+
writer.WriteNumberIfNotNull("exception_id", ExceptionId);
139+
writer.WriteNumberIfNotNull("parent_id", ParentId);
101140
writer.WriteDictionaryIfNotEmpty("data", InternalData!, logger);
102141
writer.WriteDictionaryIfNotEmpty("meta", InternalMeta!, logger);
103142

@@ -111,19 +150,27 @@ public static Mechanism FromJson(JsonElement json)
111150
{
112151
var type = json.GetPropertyOrNull("type")?.GetString();
113152
var description = json.GetPropertyOrNull("description")?.GetString();
153+
var source = json.GetPropertyOrNull("source")?.GetString();
114154
var helpLink = json.GetPropertyOrNull("help_link")?.GetString();
115155
var handled = json.GetPropertyOrNull("handled")?.GetBoolean();
116156
var synthetic = json.GetPropertyOrNull("synthetic")?.GetBoolean() ?? false;
157+
var isExceptionGroup = json.GetPropertyOrNull("is_exception_group")?.GetBoolean() ?? false;
158+
var exceptionId = json.GetPropertyOrNull("exception_id")?.GetInt32();
159+
var parentId = json.GetPropertyOrNull("parent_id")?.GetInt32();
117160
var data = json.GetPropertyOrNull("data")?.GetDictionaryOrNull();
118161
var meta = json.GetPropertyOrNull("meta")?.GetDictionaryOrNull();
119162

120163
return new Mechanism
121164
{
122165
Type = type,
123166
Description = description,
167+
Source = source,
124168
HelpLink = helpLink,
125169
Handled = handled,
126170
Synthetic = synthetic,
171+
IsExceptionGroup = isExceptionGroup,
172+
ExceptionId = exceptionId,
173+
ParentId = parentId,
127174
InternalData = data?.WhereNotNullValue().ToDictionary(),
128175
InternalMeta = meta?.WhereNotNullValue().ToDictionary()
129176
};
@@ -132,9 +179,13 @@ public static Mechanism FromJson(JsonElement json)
132179
internal bool IsDefaultOrEmpty() =>
133180
Handled is null &&
134181
Synthetic == false &&
182+
IsExceptionGroup == false &&
183+
ExceptionId is null &&
184+
ParentId is null &&
135185
Type == DefaultType &&
136186
string.IsNullOrWhiteSpace(Description) &&
137187
string.IsNullOrWhiteSpace(HelpLink) &&
188+
string.IsNullOrWhiteSpace(Source) &&
138189
!(InternalData?.Count > 0) &&
139190
!(InternalMeta?.Count > 0);
140191
}

Diff for: src/Sentry/SentryClient.cs

+8-6
Original file line numberDiff line numberDiff line change
@@ -306,13 +306,15 @@ private SentryId DoSendEvent(SentryEvent @event, Hint? hint, Scope? scope)
306306
return new[] { exception };
307307
}
308308

309-
if (exception is AggregateException aggregate &&
310-
aggregate.InnerExceptions.All(e => ApplyExceptionFilters(e) != null))
309+
if (exception is AggregateException aggregate)
311310
{
312-
// All inner exceptions of the aggregate matched a filter, so the event should be filtered.
313-
// Note that _options.KeepAggregateException is not relevant here. Even if we want to keep aggregate
314-
// exceptions, we would still never send one if all of its children are supposed to be filtered.
315-
return aggregate.InnerExceptions;
311+
// Flatten the tree of aggregates such that all the inner exceptions are non-aggregates.
312+
var innerExceptions = aggregate.Flatten().InnerExceptions;
313+
if (innerExceptions.All(e => ApplyExceptionFilters(e) != null))
314+
{
315+
// All inner exceptions matched a filter, so the event should be filtered.
316+
return innerExceptions;
317+
}
316318
}
317319

318320
// The event should not be filtered.

Diff for: src/Sentry/SentryExceptionExtensions.cs

-37
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using Sentry;
21
using Sentry.Internal;
32
using Sentry.Protocol;
43

@@ -56,40 +55,4 @@ public static void SetSentryMechanism(this Exception ex, string type, string? de
5655
ex.Data[Mechanism.HandledKey] = handled;
5756
}
5857
}
59-
60-
/// <summary>
61-
/// Recursively enumerates all <see cref="AggregateException.InnerExceptions"/> and <see cref="Exception.InnerException"/>
62-
/// Not for public use.
63-
/// </summary>
64-
[EditorBrowsable(EditorBrowsableState.Never)]
65-
public static IEnumerable<Exception> EnumerateChainedExceptions(this Exception exception, SentryOptions options)
66-
{
67-
if (exception is AggregateException aggregateException)
68-
{
69-
foreach (var inner in EnumerateInner(options, aggregateException))
70-
{
71-
yield return inner;
72-
}
73-
74-
if (!options.KeepAggregateException)
75-
{
76-
yield break;
77-
}
78-
}
79-
else if (exception.InnerException != null)
80-
{
81-
foreach (var inner in exception.InnerException.EnumerateChainedExceptions(options))
82-
{
83-
yield return inner;
84-
}
85-
}
86-
87-
yield return exception;
88-
}
89-
90-
private static IEnumerable<Exception> EnumerateInner(SentryOptions options, AggregateException aggregateException)
91-
{
92-
return aggregateException.InnerExceptions
93-
.SelectMany(exception => exception.EnumerateChainedExceptions(options));
94-
}
9558
}

Diff for: src/Sentry/SentryOptions.cs

+7-3
Original file line numberDiff line numberDiff line change
@@ -900,10 +900,14 @@ public StackTraceMode StackTraceMode
900900
public Func<bool>? CrashedLastRun { get; set; }
901901

902902
/// <summary>
903-
/// Keep <see cref="AggregateException"/> in sentry logging.
904-
/// The default behaviour is to only log <see cref="AggregateException.InnerExceptions"/> and not include the root <see cref="AggregateException"/>.
905-
/// Set KeepAggregateException to true to include the root <see cref="AggregateException"/>.
903+
/// This property is no longer used. It will be removed in a future version.
906904
/// </summary>
905+
/// <remarks>
906+
/// All exceptions are now sent to Sentry, including <see cref="AggregateException"/>s.
907+
/// The issue grouping rules in Sentry have been updated to accomodate "exception groups",
908+
/// such as <see cref="AggregateException"/> in .NET.
909+
/// </remarks>
910+
[Obsolete("This property is no longer used. It will be removed in a future version.")]
907911
public bool KeepAggregateException { get; set; }
908912

909913
/// <summary>

Diff for: test/Sentry.NLog.Tests/IntegrationTests.Simple.Core3_1.verified.txt

+2-1
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@
5353
Mechanism: {
5454
Type: generic,
5555
Handled: true,
56-
Synthetic: false
56+
Synthetic: false,
57+
IsExceptionGroup: false
5758
}
5859
}
5960
],

Diff for: test/Sentry.NLog.Tests/IntegrationTests.Simple.DotNet6_0.verified.txt

+2-1
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@
5353
Mechanism: {
5454
Type: generic,
5555
Handled: true,
56-
Synthetic: false
56+
Synthetic: false,
57+
IsExceptionGroup: false
5758
}
5859
}
5960
],

Diff for: test/Sentry.NLog.Tests/IntegrationTests.Simple.DotNet7_0.verified.txt

+2-1
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@
5353
Mechanism: {
5454
Type: generic,
5555
Handled: true,
56-
Synthetic: false
56+
Synthetic: false,
57+
IsExceptionGroup: false
5758
}
5859
}
5960
],

0 commit comments

Comments
 (0)