Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

### 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), [#4653](https://github.com/getsentry/sentry-dotnet/pull/4653))
- 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
Expand Down
35 changes: 30 additions & 5 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.IsMarkedAsPendingUnhandled)
{
status = SessionEndStatus.Unhandled;
_options.LogDebug("Session ended with pending 'Unhandled' (but not `Terminal`) exception.");
}

if (status == SessionEndStatus.Crashed)
{
// increments the errors count, as crashed sessions should report a count of 1 per:
Expand Down Expand Up @@ -288,7 +299,7 @@ public void PauseSession()

var now = _clock.GetUtcNow();
_lastPauseTimestamp = now;
PersistSession(session.CreateUpdate(false, now), now);
PersistSession(session.CreateUpdate(false, now), now, session.IsMarkedAsPendingUnhandled);
}

public IReadOnlyList<SessionUpdate> ResumeSession()
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();
}
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);
}
}
4 changes: 2 additions & 2 deletions src/Sentry/SentryClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -351,8 +351,8 @@ private SentryId DoSendEvent(SentryEvent @event, SentryHint? hint, Scope? scope)
switch (exceptionType)
{
case SentryEvent.ExceptionType.UnhandledNonTerminal:
_options.LogDebug("Ending session as 'Unhandled', due to non-terminal unhandled exception.");
scope.SessionUpdate = _sessionManager.EndSession(SessionEndStatus.Unhandled);
_options.LogDebug("Marking session as 'Unhandled', due to non-terminal unhandled exception.");
_sessionManager.MarkSessionAsUnhandled();
break;

case SentryEvent.ExceptionType.UnhandledTerminal:
Expand Down
15 changes: 15 additions & 0 deletions src/Sentry/SentrySession.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Sentry.Internal;

namespace Sentry;

/// <summary>
Expand Down Expand Up @@ -36,6 +38,13 @@ public class SentrySession : ISentrySession
// Start at -1 so that the first increment puts it at 0
private int _sequenceNumber = -1;

private InterlockedBoolean _isMarkedAsPendingUnhandled;

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

internal SentrySession(
SentryId id,
string? distinctId,
Expand Down Expand Up @@ -74,6 +83,12 @@ 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() => _isMarkedAsPendingUnhandled.Exchange(true);

internal SessionUpdate CreateUpdate(
bool isInitial,
DateTimeOffset timestamp,
Expand Down
155 changes: 155 additions & 0 deletions test/Sentry.Tests/GlobalSessionManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,161 @@ public void TryRecoverPersistedSession_HasRecoveredUpdateAndCrashedLastRunFailed
TryRecoverPersistedSessionWithExceptionOnLastRun();
}

[Fact]
public void MarkSessionAsUnhandled_ActiveSessionExists_MarksSessionAndPersists()
{
// Arrange
var sut = _fixture.GetSut();
sut.StartSession();
var session = sut.CurrentSession;

// Act
sut.MarkSessionAsUnhandled();

// Assert
session.Should().NotBeNull();
session!.IsMarkedAsPendingUnhandled.Should().BeTrue();

// Session should still be active (not ended)
sut.CurrentSession.Should().BeSameAs(session);
}

[Fact]
public void MarkSessionAsUnhandled_NoActiveSession_LogsDebug()
{
// Arrange
var sut = _fixture.GetSut();

// Act
sut.MarkSessionAsUnhandled();

// Assert
_fixture.Logger.Entries.Should().Contain(e =>
e.Message == "There is no session active. Skipping marking session as unhandled." &&
e.Level == SentryLevel.Debug);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Session Logging Mismatch Causes Test Failure

The MarkSessionAsUnhandled method in GlobalSessionManager.cs logs "There is no session active. Skipping marking session as unhandled." when no session is active. This differs from the "No active session to mark as unhandled." message expected by the MarkSessionAsUnhandled_NoActiveSession_LogsDebug test, causing the test to fail.

Additional Locations (1)

Fix in Cursor Fix in Web

}

[Fact]
public void TryRecoverPersistedSession_WithPendingUnhandledAndNoCrash_EndsAsUnhandled()
{
// Arrange
_fixture.Options.CrashedLastRun = () => false;
_fixture.PersistedSessionProvider = _ => new PersistedSessionUpdate(
AnySessionUpdate(),
pauseTimestamp: null,
pendingUnhandled: true);

var sut = _fixture.GetSut();

// Act
var persistedSessionUpdate = sut.TryRecoverPersistedSession();

// Assert
persistedSessionUpdate.Should().NotBeNull();
persistedSessionUpdate!.EndStatus.Should().Be(SessionEndStatus.Unhandled);
}

[Fact]
public void TryRecoverPersistedSession_WithPendingUnhandledAndCrash_EscalatesToCrashed()
{
// Arrange
_fixture.Options.CrashedLastRun = () => true;
_fixture.PersistedSessionProvider = _ => new PersistedSessionUpdate(
AnySessionUpdate(),
pauseTimestamp: null,
pendingUnhandled: true);

var sut = _fixture.GetSut();

// Act
var persistedSessionUpdate = sut.TryRecoverPersistedSession();

// Assert
persistedSessionUpdate.Should().NotBeNull();
persistedSessionUpdate!.EndStatus.Should().Be(SessionEndStatus.Crashed);
}

[Fact]
public void TryRecoverPersistedSession_WithPendingUnhandledAndPauseTimestamp_EscalatesToCrashedIfCrashed()
{
// Arrange - Session was paused AND had pending unhandled, then crashed
_fixture.Options.CrashedLastRun = () => true;
var pausedTimestamp = DateTimeOffset.Now;
_fixture.PersistedSessionProvider = _ => new PersistedSessionUpdate(
AnySessionUpdate(),
pausedTimestamp,
pendingUnhandled: true);

var sut = _fixture.GetSut();

// Act
var persistedSessionUpdate = sut.TryRecoverPersistedSession();

// Assert
// Crash takes priority over all other end statuses
persistedSessionUpdate.Should().NotBeNull();
persistedSessionUpdate!.EndStatus.Should().Be(SessionEndStatus.Crashed);
}

[Fact]
public void EndSession_WithPendingUnhandledException_PreservesUnhandledStatus()
{
// Arrange
var sut = _fixture.GetSut();
sut.StartSession();
sut.MarkSessionAsUnhandled();

// Act - Try to end normally with Exited status
var sessionUpdate = sut.EndSession(SessionEndStatus.Exited);

// Assert - Should be overridden to Unhandled
sessionUpdate.Should().NotBeNull();
sessionUpdate!.EndStatus.Should().Be(SessionEndStatus.Unhandled);
}

[Fact]
public void EndSession_WithPendingUnhandledAndCrashedStatus_UsesCrashedStatus()
{
// Arrange
var sut = _fixture.GetSut();
sut.StartSession();
sut.MarkSessionAsUnhandled();

// Act - Explicitly end with Crashed status
var sessionUpdate = sut.EndSession(SessionEndStatus.Crashed);

// Assert - Crashed status takes priority
sessionUpdate.Should().NotBeNull();
sessionUpdate!.EndStatus.Should().Be(SessionEndStatus.Crashed);
sessionUpdate.ErrorCount.Should().Be(1);
}

[Fact]
public void SessionEscalation_CompleteFlow_UnhandledThenCrash()
{
// Arrange - Simulate complete flow
var sut = _fixture.GetSut();
sut.StartSession();
var originalSessionId = sut.CurrentSession!.Id;

// Act 1: Mark as unhandled (game encounters exception but continues)
sut.MarkSessionAsUnhandled();

// Assert: Session still active with pending flag
sut.CurrentSession.Should().NotBeNull();
sut.CurrentSession!.Id.Should().Be(originalSessionId);
sut.CurrentSession.IsMarkedAsPendingUnhandled.Should().BeTrue();

// Act 2: Recover on next launch with crash detected
_fixture.Options.CrashedLastRun = () => true;
var recovered = sut.TryRecoverPersistedSession();

// Assert: Session escalated from Unhandled to Crashed
recovered.Should().NotBeNull();
recovered!.EndStatus.Should().Be(SessionEndStatus.Crashed);
recovered.Id.Should().Be(originalSessionId);
}

// A session update (of which the state doesn't matter for the test):
private static SessionUpdate AnySessionUpdate()
=> new(
Expand Down
4 changes: 2 additions & 2 deletions test/Sentry.Tests/SentryClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1649,7 +1649,7 @@ public void CaptureEvent_ActiveSessionAndUnhandledException_SessionEndedAsCrashe
}

[Fact]
public void CaptureEvent_ActiveSessionAndNonTerminalUnhandledException_SessionEndedAsUnhandled()
public void CaptureEvent_ActiveSessionAndNonTerminalUnhandledException_SessionMarkedAsUnhandled()
{
// Arrange
var client = _fixture.GetSut();
Expand All @@ -1660,6 +1660,6 @@ public void CaptureEvent_ActiveSessionAndNonTerminalUnhandledException_SessionEn
client.CaptureEvent(new SentryEvent(exception));

// Assert
_fixture.SessionManager.Received().EndSession(SessionEndStatus.Unhandled);
_fixture.SessionManager.Received().MarkSessionAsUnhandled();
}
}
Loading