Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
c3d01c1
Initial setup
bitsandfoxes Oct 14, 2025
38ac24d
Move terminal state into mechanism
bitsandfoxes Oct 15, 2025
54d3b2f
Format code
getsentry-bot Oct 15, 2025
f44a687
Made Terminal nullable
bitsandfoxes Oct 15, 2025
de5e1b5
Merge branch 'feat/session-type-unhandled' of https://github.com/gets…
bitsandfoxes Oct 15, 2025
b6c4c58
Updated CHANGELOG.md
bitsandfoxes Oct 15, 2025
dec8f34
Bump because vulnerability
bitsandfoxes Oct 15, 2025
16179d8
Conditionally add the terminal key
bitsandfoxes Oct 15, 2025
3ecb136
Updated verify
bitsandfoxes Oct 16, 2025
d657e0b
merged version6
bitsandfoxes Oct 16, 2025
d9c2d73
Cache unhandled sessions instead of sending right away
bitsandfoxes Oct 17, 2025
f95bff3
Merge branch 'version6' into feat/session-type-unhandled
bitsandfoxes Oct 17, 2025
fac1ab4
Moved 'terminal' into data bag
bitsandfoxes Oct 17, 2025
7a59034
Keep the key
bitsandfoxes Oct 17, 2025
413a9e8
Merge branch 'feat/session-type-unhandled' into feat/cache-unhandled-…
bitsandfoxes Oct 17, 2025
660714e
.
bitsandfoxes Oct 17, 2025
0c47b3f
Updated verify for net48
bitsandfoxes Oct 20, 2025
04d8889
Wrap exception type with enum
bitsandfoxes Oct 20, 2025
4c24f51
Logging
bitsandfoxes Oct 20, 2025
bbefdd3
Prevent Mechanism.TerminalKey from being serialized
bitsandfoxes Oct 20, 2025
fe4e915
Filter Terminal in WriteTo
bitsandfoxes Oct 20, 2025
31416f9
Make TerminalKey top level but don't serialize
bitsandfoxes Oct 20, 2025
1ad719c
Fixed tests
bitsandfoxes Oct 20, 2025
6508e5e
Pulled unhandled changes into this
bitsandfoxes Oct 20, 2025
4d6fb3f
Replaced API
bitsandfoxes Oct 20, 2025
1348053
Merge branch 'feat/session-type-unhandled' into feat/cache-unhandled-…
bitsandfoxes Oct 20, 2025
624524f
Added net4_8 verify
bitsandfoxes Oct 20, 2025
0502cd7
Merge branch 'feat/session-type-unhandled' into feat/cache-unhandled-…
bitsandfoxes Oct 20, 2025
b94c400
Updated CHANGELOG.md
bitsandfoxes Oct 20, 2025
225f67a
Cleanup
bitsandfoxes Oct 20, 2025
f06513d
Is this the one that is missing?
bitsandfoxes Oct 21, 2025
7527bdd
Merge branch 'feat/cache-unhandled-session' of https://github.com/get…
bitsandfoxes Oct 21, 2025
01d6bb8
Fixed my own mess. yey.
bitsandfoxes Oct 21, 2025
a7ff327
Interlock marking
bitsandfoxes Oct 21, 2025
a41b316
Update src/Sentry/Platforms/Android/LogCatAttachmentEventProcessor.cs
bitsandfoxes Oct 24, 2025
352fd01
Merge branch 'version6' into feat/session-type-unhandled
bitsandfoxes Oct 24, 2025
a1500c3
Unhandled -> UnhandledTerminal
bitsandfoxes Oct 24, 2025
73a6c01
Apply suggestions from code review
bitsandfoxes Oct 29, 2025
410c373
Merge branch 'feat/session-type-unhandled' into feat/cache-unhandled-…
bitsandfoxes Oct 29, 2025
35784e6
Merge branch 'feat/cache-unhandled-session' of https://github.com/get…
bitsandfoxes Oct 29, 2025
1a72f29
Fix import and persist unhandled mark when pausing
bitsandfoxes Oct 29, 2025
32bf6d2
Merged 'version6'
bitsandfoxes Oct 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
- Backpressure handling is now enabled by default, meaning that the SDK will monitor system health and reduce the sampling rate of events and transactions when the system is under load. When the system is determined to be healthy again, the sampling rates are returned to their original levels. ([#4615](https://github.com/getsentry/sentry-dotnet/pull/4615))
- ScopeExtensions.Populate is now internal ([#4611](https://github.com/getsentry/sentry-dotnet/pull/4611))

### Features

- The SDK now makes use of the new SessionEndStatus `Unhandled` when capturing an unhandled but non-terminal exception, i.e. through the UnobservedTaskExceptionIntegration ([#4633](https://github.com/getsentry/sentry-dotnet/pull/4633))

### Fixes

- The SDK avoids redundant scope sync after transaction finish ([#4623](https://github.com/getsentry/sentry-dotnet/pull/4623))
Expand Down
33 changes: 29 additions & 4 deletions src/Sentry/GlobalSessionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public GlobalSessionManager(

// Take pause timestamp directly instead of referencing _lastPauseTimestamp to avoid
// potential race conditions.
private void PersistSession(SessionUpdate update, DateTimeOffset? pauseTimestamp = null)
private void PersistSession(SessionUpdate update, DateTimeOffset? pauseTimestamp = null, bool pendingUnhandled = false)
{
_options.LogDebug("Persisting session (SID: '{0}') to a file.", update.Id);

Expand Down Expand Up @@ -69,7 +69,7 @@ private void PersistSession(SessionUpdate update, DateTimeOffset? pauseTimestamp

var filePath = Path.Combine(_persistenceDirectoryPath, PersistedSessionFileName);

var persistedSessionUpdate = new PersistedSessionUpdate(update, pauseTimestamp);
var persistedSessionUpdate = new PersistedSessionUpdate(update, pauseTimestamp, pendingUnhandled);
if (!_options.FileSystem.CreateFileForWriting(filePath, out var file))
{
_options.LogError("Failed to persist session file.");
Expand Down Expand Up @@ -161,7 +161,10 @@ private void DeletePersistedSession()
status = _options.CrashedLastRun?.Invoke() switch
{
// Native crash (if native SDK enabled):
// This takes priority - escalate to Crashed even if session had pending unhandled
true => SessionEndStatus.Crashed,
// Had unhandled exception but didn't crash:
_ when recoveredUpdate.PendingUnhandled => SessionEndStatus.Unhandled,
// Ended while on the background, healthy session:
_ when recoveredUpdate.PauseTimestamp is not null => SessionEndStatus.Exited,
// Possibly out of battery, killed by OS or user, solar flare:
Expand All @@ -185,9 +188,10 @@ private void DeletePersistedSession()
// If there's a callback for native crashes, check that first.
status);

_options.LogInfo("Recovered session: EndStatus: {0}. PauseTimestamp: {1}",
_options.LogInfo("Recovered session: EndStatus: {0}. PauseTimestamp: {1}. PendingUnhandled: {2}",
sessionUpdate.EndStatus,
recoveredUpdate.PauseTimestamp);
recoveredUpdate.PauseTimestamp,
recoveredUpdate.PendingUnhandled);

return sessionUpdate;
}
Expand Down Expand Up @@ -245,6 +249,13 @@ private void DeletePersistedSession()

private SessionUpdate EndSession(SentrySession session, DateTimeOffset timestamp, SessionEndStatus status)
{
// If we're ending as 'Exited' but he session has a pending 'Unhandled', end as 'Unhandled'
if (status == SessionEndStatus.Exited && session.HasPendingUnhandledException)
{
status = SessionEndStatus.Unhandled;
_options.LogDebug("Session ended as 'Unhandled' due to pending status.");
}

if (status == SessionEndStatus.Crashed)
{
// increments the errors count, as crashed sessions should report a count of 1 per:
Expand Down Expand Up @@ -364,4 +375,18 @@ public IReadOnlyList<SessionUpdate> ResumeSession()

return session.CreateUpdate(false, _clock.GetUtcNow());
}

public void MarkSessionAsUnhandled()
{
if (_currentSession is not { } session)
{
_options.LogDebug("There is no session active. Skipping marking session as unhandled.");
return;
}

session.MarkUnhandledException();

var sessionUpdate = session.CreateUpdate(false, _clock.GetUtcNow());
PersistSession(sessionUpdate, pendingUnhandled: true);
}
}
2 changes: 2 additions & 0 deletions src/Sentry/ISessionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ internal interface ISessionManager
public IReadOnlyList<SessionUpdate> ResumeSession();

public SessionUpdate? ReportError();

public void MarkSessionAsUnhandled();
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ internal void Handle(object? sender, UnobservedTaskExceptionEventArgs e)
MechanismKey,
"This exception was thrown from a task that was unobserved, such as from an async void method, or " +
"a Task.Run that was not awaited. This exception was unhandled, but likely did not crash the application.",
handled: false);
handled: false,
terminal: false);

// Call the internal implementation, so that we still capture even if the hub has been disabled.
_hub.CaptureExceptionInternal(ex);
Expand Down
3 changes: 2 additions & 1 deletion src/Sentry/Internal/Hub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,8 @@ private SentryId CaptureEvent(SentryEvent evt, SentryHint? hint, Scope scope)
scope.LastEventId = id;
scope.SessionUpdate = null;

if (evt.HasTerminalException() && scope.Transaction is { } transaction)
if (evt.GetExceptionType() is SentryEvent.ExceptionType.Unhandled
&& scope.Transaction is { } transaction)
{
// Event contains a terminal exception -> finish any current transaction as aborted
// Do this *after* the event was captured, so that the event is still linked to the transaction.
Expand Down
6 changes: 6 additions & 0 deletions src/Sentry/Internal/MainExceptionProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,12 @@ private static Mechanism GetMechanism(Exception exception, int id, int? parentId
exception.Data.Remove(Mechanism.DescriptionKey);
}

if (exception.Data[Mechanism.TerminalKey] is bool terminal)
{
mechanism.Terminal = terminal;
exception.Data.Remove(Mechanism.TerminalKey);
}

// Add HResult to mechanism data before adding exception data, so that it can be overridden.
mechanism.Data["HResult"] = $"0x{exception.HResult:X8}";

Expand Down
13 changes: 11 additions & 2 deletions src/Sentry/PersistedSessionUpdate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ internal class PersistedSessionUpdate : ISentryJsonSerializable

public DateTimeOffset? PauseTimestamp { get; }

public PersistedSessionUpdate(SessionUpdate update, DateTimeOffset? pauseTimestamp)
public bool PendingUnhandled { get; }

public PersistedSessionUpdate(SessionUpdate update, DateTimeOffset? pauseTimestamp, bool pendingUnhandled = false)
{
Update = update;
PauseTimestamp = pauseTimestamp;
PendingUnhandled = pendingUnhandled;
}

public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger)
Expand All @@ -26,14 +29,20 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger)
writer.WriteString("paused", pauseTimestamp);
}

if (PendingUnhandled)
{
writer.WriteBoolean("pendingUnhandled", PendingUnhandled);
}

writer.WriteEndObject();
}

public static PersistedSessionUpdate FromJson(JsonElement json)
{
var update = SessionUpdate.FromJson(json.GetProperty("update"));
var pauseTimestamp = json.GetPropertyOrNull("paused")?.GetDateTimeOffset();
var pendingUnhandled = json.GetPropertyOrNull("pendingUnhandled")?.GetBoolean() ?? false;

return new PersistedSessionUpdate(update, pauseTimestamp);
return new PersistedSessionUpdate(update, pauseTimestamp, pendingUnhandled);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,17 @@ public SentryEvent Process(SentryEvent @event, SentryHint hint)

try
{
if (_logCatIntegrationType != LogCatIntegrationType.All && [email protected]())
var exceptionType = @event.GetExceptionType();

if (_logCatIntegrationType != LogCatIntegrationType.All && exceptionType == SentryEvent.ExceptionType.None)
{
return @event;
}

// Only send logcat logs if the event is unhandled if the integration is set to Unhandled
if (_logCatIntegrationType == LogCatIntegrationType.Unhandled)
{
if ([email protected]())
if (exceptionType != SentryEvent.ExceptionType.Unhandled)
{
return @event;
}
Expand Down
18 changes: 18 additions & 0 deletions src/Sentry/Protocol/Mechanism.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ public sealed class Mechanism : ISentryJsonSerializable
/// </summary>
public static readonly string DescriptionKey = "Sentry:Description";

/// <summary>
/// Key found inside of <c>Exception.Data</c> describing whether the exception is considered terminal.
/// </summary>
/// <remarks>
/// This is an SDK-internal flag used for session tracking and is not sent to Sentry servers.
/// </remarks>
public static readonly string TerminalKey = "Sentry:Terminal";

internal Dictionary<string, object>? InternalData { get; private set; }

internal Dictionary<string, object>? InternalMeta { get; private set; }
Expand Down Expand Up @@ -76,6 +84,15 @@ public string Type
/// </summary>
public bool? Handled { get; set; }

/// <summary>
/// Optional flag indicating whether the exception is terminal (will crash the application).
/// When false, indicates a non-terminal unhandled exception (e.g., unobserved task exception).
/// </summary>
/// <remarks>
/// This is an SDK-internal flag used for session tracking and is not serialized to Sentry servers.
/// </remarks>
public bool? Terminal { get; internal set; }

/// <summary>
/// Optional flag indicating whether the exception is synthetic.
/// </summary>
Expand Down Expand Up @@ -133,6 +150,7 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger)
writer.WriteStringIfNotWhiteSpace("source", Source);
writer.WriteStringIfNotWhiteSpace("help_link", HelpLink);
writer.WriteBooleanIfNotNull("handled", Handled);
// Note: Terminal is NOT serialized - it's SDK-internal only
writer.WriteBooleanIfTrue("synthetic", Synthetic);
writer.WriteBooleanIfTrue("is_exception_group", IsExceptionGroup);
writer.WriteNumberIfNotNull("exception_id", ExceptionId);
Expand Down
29 changes: 17 additions & 12 deletions src/Sentry/SentryClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -347,18 +347,23 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope)
return SentryId.Empty; // Dropped by BeforeSend callback
}

var hasTerminalException = processedEvent.HasTerminalException();
if (hasTerminalException)
{
// Event contains a terminal exception -> end session as crashed
_options.LogDebug("Ending session as Crashed, due to unhandled exception.");
scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Crashed);
}
else if (processedEvent.HasException())
{
// Event contains a non-terminal exception -> report error
// (this might return null if the session has already reported errors before)
scope.SessionUpdate = _sessionManager.ReportError();
var exceptionType = processedEvent.GetExceptionType();
switch (exceptionType)
{
case SentryEvent.ExceptionType.UnhandledNonTerminal:
_options.LogDebug("Ending session as 'Unhandled', due to non-terminal unhandled exception.");
_sessionManager.MarkSessionAsUnhandled();
break;

case SentryEvent.ExceptionType.Unhandled:
_options.LogDebug("Ending session as 'Crashed', due to unhandled exception.");
scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Crashed);
break;

case SentryEvent.ExceptionType.Handled:
_options.LogDebug("Updating session by reporting an error.");
scope.SessionUpdate = _sessionManager.ReportError();
break;
}

if (_options.SampleRate != null)
Expand Down
57 changes: 50 additions & 7 deletions src/Sentry/SentryEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,21 +178,64 @@ public IReadOnlyList<string> Fingerprint
/// <inheritdoc />
public IReadOnlyDictionary<string, string> Tags => _tags ??= new Dictionary<string, string>();

internal bool HasException() => Exception is not null || SentryExceptions?.Any() == true;
internal enum ExceptionType
{
None,
Handled,
Unhandled,
UnhandledNonTerminal
}

internal bool HasTerminalException()
internal ExceptionType GetExceptionType()
{
// The exception is considered terminal if it is marked unhandled,
// UNLESS it comes from the UnobservedTaskExceptionIntegration
if (!HasException())
{
return ExceptionType.None;
}

if (HasUnhandledNonTerminalException())
{
return ExceptionType.UnhandledNonTerminal;
}

if (HasUnhandledException())
{
return ExceptionType.Unhandled;
}

return ExceptionType.Handled;
}

private bool HasException() => Exception is not null || SentryExceptions?.Any() == true;

private bool HasUnhandledException()
{
if (Exception?.Data[Mechanism.HandledKey] is false)
{
return Exception.Data[Mechanism.MechanismKey] as string != UnobservedTaskExceptionIntegration.MechanismKey;
return true;
}

return SentryExceptions?.Any(e => e.Mechanism is { Handled: false }) ?? false;
}

private bool HasUnhandledNonTerminalException()
{
// Generally, an unhandled exception is considered terminal.
// Exception: If it is an unhandled exception but the terminal flag is explicitly set to false.
// I.e. captured through the UnobservedTaskExceptionIntegration, or the exception capture integrations in the Unity SDK

if (Exception?.Data[Mechanism.HandledKey] is false)
{
if (Exception.Data[Mechanism.TerminalKey] is false)
{
return true;
}

return false;
}

return SentryExceptions?.Any(e =>
e.Mechanism is { Handled: false } mechanism &&
mechanism.Type != UnobservedTaskExceptionIntegration.MechanismKey
e.Mechanism is { Handled: false, Terminal: false }
) ?? false;
}

Expand Down
12 changes: 11 additions & 1 deletion src/Sentry/SentryExceptionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ public static void AddSentryContext(this Exception ex, string name, IReadOnlyDic
/// <param name="type">A required short string that identifies the mechanism.</param>
/// <param name="description">An optional human-readable description of the mechanism.</param>
/// <param name="handled">An optional flag indicating whether the exception was handled by the mechanism.</param>
/// <param name="terminal">An optional flag indicating whether the exception is considered terminal.</param>
public static void SetSentryMechanism(this Exception ex, string type, string? description = null,
bool? handled = null)
bool? handled = null, bool? terminal = null)
{
ex.Data[Mechanism.MechanismKey] = type;

Expand All @@ -54,5 +55,14 @@ public static void SetSentryMechanism(this Exception ex, string type, string? de
{
ex.Data[Mechanism.HandledKey] = handled;
}

if (terminal == null)
{
ex.Data.Remove(Mechanism.TerminalKey);
}
else
{
ex.Data[Mechanism.TerminalKey] = terminal;
}
}
}
17 changes: 17 additions & 0 deletions src/Sentry/SentrySession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ public class SentrySession : ISentrySession
// Start at -1 so that the first increment puts it at 0
private int _sequenceNumber = -1;

private bool _hasPendingUnhandledException;

/// <summary>
/// Gets whether this session has an unhandled exception that hasn't been finalized yet.
/// </summary>
internal bool HasPendingUnhandledException => _hasPendingUnhandledException;

internal SentrySession(
SentryId id,
string? distinctId,
Expand Down Expand Up @@ -74,6 +81,16 @@ public SentrySession(string? distinctId, string release, string? environment)
/// </summary>
public void ReportError() => Interlocked.Increment(ref _errorCount);

/// <summary>
/// Marks the session as having an unhandled exception without ending it.
/// This allows the session to continue and potentially escalate to Crashed if the app crashes.
/// </summary>
internal void MarkUnhandledException()
{
_hasPendingUnhandledException = true;
ReportError();
}
Copy link

Choose a reason for hiding this comment

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

Bug: Error Count Incrementation Bug

The MarkUnhandledException method increments the session's error count on every call. This results in the error count increasing multiple times for a single unhandled state, rather than just once as intended.

Fix in Cursor Fix in Web


internal SessionUpdate CreateUpdate(
bool isInitial,
DateTimeOffset timestamp,
Expand Down
Loading
Loading