Skip to content

feat: Auto-create traces for MAUI navigation events#5111

Open
jamescrosswell wants to merge 19 commits intomainfrom
maui-transactions-5109
Open

feat: Auto-create traces for MAUI navigation events#5111
jamescrosswell wants to merge 19 commits intomainfrom
maui-transactions-5109

Conversation

@jamescrosswell
Copy link
Copy Markdown
Collaborator

@jamescrosswell jamescrosswell commented Apr 2, 2026

Resolves: #5109

Implementation

Tracing for UI applications is a little bit complex.

In principle, we start a transaction when a navigation event is initiated and finish it when the navigation completes... sounds easy.

In practice, very little work will be done on the main UI thread as we don't want to freeze/block this. So a navigation event might start, a new view loads, a spinner icon animation kicks off and, technically, at that point, the navigation is completed. In reality the app may be implementing some kind of progressive display and loading of portions of that display is still taking place in the background (getting some stuff from a database to load up in the main panel, pulling access control credentials from a remote source to determine what actions should be available on the menu etc.).

The solution we have then is to start a new transaction with a idle timer on it:

  • If the idle timeout is reached before any other activity happens on the transaction (child spans added) then we simply discard the transaction as trivial/irrelevant.
  • If a child span kicks off on the transaction though, we reset the idle timeout... and we keep doing that as long as there is activity
  • When the idle timeout finally triggers, if there are child spans on the transaction, the end date/time for the transaction is set as the end date/time of the last child span to finish

This won't be accurate 100% of the time, but it should be a pretty good approximation most of the time.

Out of scope

The Android and Cocoa SDKs instrument traces for navigation, gesture and scroll events. This PR does so only for navigation events.

We plan to implement automated traces for gesture events in a followup PR:

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 2, 2026

Semver Impact of This PR

None (no version bump detected)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


Features ✨

  • feat: Auto-create traces for MAUI navigation events by jamescrosswell in #5111

🤖 This preview updates automatically when you update the PR.

/// Starts a transaction that will automatically finish after <paramref name="idleTimeout"/> if not
/// finished explicitly first.
/// </summary>
public ITransactionTracer StartTransaction(ITransactionContext context, TimeSpan? idleTimeout);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Technically it'd be a breaking change if we added this method to the public IHub interface. Potentially in v7 we could consider making this public and getting rid of the IHubInternal interface.

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 8, 2026

Codecov Report

❌ Patch coverage is 72.26277% with 38 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.00%. Comparing base (ed4f9a1) to head (7a0395a).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
src/Sentry/TransactionTracer.cs 73.97% 11 Missing and 8 partials ⚠️
src/Sentry/Infrastructure/SystemTimer.cs 0.00% 6 Missing ⚠️
src/Sentry/SentrySdk.cs 0.00% 5 Missing and 1 partial ⚠️
src/Sentry.Maui/Internal/MauiEventsBinder.cs 92.50% 0 Missing and 3 partials ⚠️
src/Sentry/Extensibility/DisabledHub.cs 0.00% 1 Missing ⚠️
src/Sentry/Extensibility/HubAdapter.cs 0.00% 1 Missing ⚠️
src/Sentry/Internal/Hub.cs 50.00% 1 Missing ⚠️
src/Sentry/Internal/NoOpTransaction.cs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5111      +/-   ##
==========================================
+ Coverage   73.99%   74.00%   +0.01%     
==========================================
  Files         499      500       +1     
  Lines       18067    18181     +114     
  Branches     3520     3555      +35     
==========================================
+ Hits        13368    13455      +87     
- Misses       3839     3859      +20     
- Partials      860      867       +7     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.


var transaction = _hub is IHubInternal internalHub
? internalHub.StartTransaction(context, _options.NavigationTransactionIdleTimeout)
: _hub.StartTransaction(context);
Copy link
Copy Markdown
Collaborator Author

@jamescrosswell jamescrosswell Apr 9, 2026

Choose a reason for hiding this comment

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

In practice, everything that implements IHub also implements IHubInternal and vice versa... so this code path will never execute.

Potentially we could replace it with an UnreachableException... that seems more dangerous (albeit more explicit/readable).

Maybe rather than using an UnreachableException then, we just add code comment here to explain.

@Flash0ver thoughts?

/// <summary>
/// Resets the idle timeout for auto-finishing transactions. No-op for transactions without an idle timeout.
/// </summary>
public void ResetIdleTimeout();
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Technically this would be a breaking change as well... we could just put this (as an internal) on the concrete class rather than the interface.

@Flash0ver what do you think?

{
hub.GetSentryOptions()?.LogError("Attempting to initianise the SentrySdk with a HubAdapter can lead to infinite recursion. Initialisation cancelled.");
return DisabledHub.Instance;
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I added this to (try to) stop Warden from complaining about a potential infinite recursion... since HubAdapter forwards all calls to SentrySdk.CurrentHub.

We could throw an exception here instead of logging and returning a DisabledHub but in practice, HubAdapter would never be assigned to SentrySdk.CurrentHub so I think what we have here is already more than enough (arguably unecessary).

@jamescrosswell jamescrosswell marked this pull request as ready for review April 9, 2026 01:49
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Missing ObjectDisposedException handling in ChildSpanFinished
    • Wrapped the idle timer restart in ChildSpanFinished with an ObjectDisposedException catch to handle concurrent Finish() disposal safely.
  • ✅ Fixed: Discarded transaction appears active, blocking new transaction creation
    • Set EndTimestamp in the idle-timeout discard path so discarded transactions report IsFinished == true and are not reused.

Create PR

Or push these changes by commenting:

@cursor push 8bb9e11346
Preview (8bb9e11346)
diff --git a/src/Sentry/TransactionTracer.cs b/src/Sentry/TransactionTracer.cs
--- a/src/Sentry/TransactionTracer.cs
+++ b/src/Sentry/TransactionTracer.cs
@@ -287,6 +287,7 @@
         if (shouldDiscard)
         {
             _options?.LogDebug("Idle transaction '{0}' has no child spans. Discarding.", SpanId);
+            EndTimestamp ??= _stopwatch.CurrentDateTimeOffset;
             _idleTimer?.Dispose();
             _hub.ConfigureScope(static (scope, tracer) => scope.ResetTransaction(tracer), this);
             return;
@@ -378,7 +379,15 @@
         // Only restart the idle timer when there are no more active (unfinished) child spans
         if (_activeSpanTracker.PeekActive() == null)
         {
-            _idleTimer?.Start(_idleTimeout.Value);
+            try
+            {
+                _idleTimer?.Start(_idleTimeout.Value);
+            }
+            catch (ObjectDisposedException)
+            {
+                // Finish() may dispose the timer concurrently between the _hasFinished check and Start().
+                // Swallow the exception — the transaction is already finishing.
+            }
         }
     }
 

diff --git a/test/Sentry.Tests/TransactionTracerTests.cs b/test/Sentry.Tests/TransactionTracerTests.cs
--- a/test/Sentry.Tests/TransactionTracerTests.cs
+++ b/test/Sentry.Tests/TransactionTracerTests.cs
@@ -6,6 +6,29 @@
 {
     private static readonly TimeSpan AnyTimeout = TimeSpan.FromSeconds(30);
 
+    private sealed class ThrowOnDisposedTimer : ISentryTimer
+    {
+        private bool _isDisposed;
+
+        public ThrowOnDisposedTimer(Action _)
+        {
+        }
+
+        public void Start(TimeSpan timeout)
+        {
+            if (_isDisposed)
+            {
+                throw new ObjectDisposedException(nameof(ThrowOnDisposedTimer));
+            }
+        }
+
+        public void Cancel()
+        {
+        }
+
+        public void Dispose() => _isDisposed = true;
+    }
+
     private static (TransactionTracer transaction, MockTimer timer) CreateIdleTransaction(
         IHub hub, string name = "name", string op = "op")
     {
@@ -122,16 +145,42 @@
     {
         // Given an auto-generated UI event transaction with no child spans
         var hub = Substitute.For<IHub>();
-        var (_, timer) = CreateIdleTransaction(hub);
+        var (transaction, timer) = CreateIdleTransaction(hub);
 
         // When the idleTimeout fires
         timer.Fire();
 
         // Then the SDK discards the transaction (does not capture it)
         hub.DidNotReceive().CaptureTransaction(Arg.Any<SentryTransaction>());
+        transaction.IsFinished.Should().BeTrue();
+        transaction.EndTimestamp.Should().NotBeNull();
     }
 
     [Fact]
+    public void ChildSpanFinished_DisposedIdleTimer_DoesNotThrow()
+    {
+        // Given an idle transaction whose timer has been disposed after the finished-state check
+        var hub = Substitute.For<IHub>();
+        ThrowOnDisposedTimer? disposedTimer = null;
+        var transaction = new TransactionTracer(
+            hub,
+            new TransactionContext("name", "op"),
+            idleTimeout: AnyTimeout,
+            timerFactory: callback =>
+            {
+                disposedTimer = new ThrowOnDisposedTimer(callback);
+                return disposedTimer;
+            });
+        disposedTimer!.Dispose();
+
+        // When ChildSpanFinished attempts to restart the disposed timer
+        var action = () => transaction.ChildSpanFinished();
+
+        // Then no ObjectDisposedException escapes
+        action.Should().NotThrow();
+    }
+
+    [Fact]
     public void IdleTimeout_WithFinishedChildSpan_TrimsEndTimestampToLatestSpan()
     {
         // Given an auto-generated UI event transaction with one finished child span

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b9e4ff1. Configure here.

@jamescrosswell jamescrosswell marked this pull request as draft April 9, 2026 02:24
@jamescrosswell jamescrosswell marked this pull request as ready for review April 9, 2026 03:25

private InterlockedBoolean _cancelIdleTimeout;
private bool _hasFinished;
private readonly ReaderWriterLockSlim _finishLock = new();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ReaderWriterLockSlim _finishLock is never disposed

The _finishLock field of type ReaderWriterLockSlim is created but never disposed. ReaderWriterLockSlim implements IDisposable and holds native resources that should be released. The Dispose() method only calls Finish() which disposes _idleTimer but not _finishLock, leading to potential resource leaks when many transactions are created.

Verification

Read the full TransactionTracer.cs file. Verified that _finishLock is a ReaderWriterLockSlim (line 21). Checked ISentryTimer interface (extends IDisposable). Examined Dispose() method (lines 568-571) which only calls Finish(). Examined TryFinishOnce() (lines 451-469) which disposes _idleTimer but not _finishLock. Confirmed ReaderWriterLockSlim implements IDisposable.

Identified by Warden code-review · 9TQ-89C

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Transactions for UI events in MAUI

2 participants