Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8d40272
feat: Auto-create traces for MAUI navigation events
jamescrosswell Apr 2, 2026
4041119
Format code
getsentry-bot Apr 2, 2026
09760b3
Discard UI event transactions without child spans
jamescrosswell Apr 7, 2026
3a8c8f0
missing verify files
jamescrosswell Apr 7, 2026
86a36db
Clean up duplicate tests
jamescrosswell Apr 7, 2026
e02ac47
Fix scope issue for detecting manual transactions
jamescrosswell Apr 7, 2026
e0145f4
Fix race conditions
jamescrosswell Apr 7, 2026
67c36c3
Fix no transactions being created when user has a transaction on the …
jamescrosswell Apr 7, 2026
2b71e0c
Address test gaps
jamescrosswell Apr 7, 2026
430ec80
Matched tests with Android implementation
jamescrosswell Apr 7, 2026
54b3d45
Review feedback
jamescrosswell Apr 7, 2026
5f73295
Merge remote-tracking branch 'origin/main' into maui-transactions-5109
jamescrosswell Apr 8, 2026
a3b5e22
Format code
getsentry-bot Apr 8, 2026
0d5a5f4
Windows verify tests
jamescrosswell Apr 8, 2026
0418c99
Add runtime guard to ensure HubAdaptes is never set to SentrySdk.Curr…
jamescrosswell Apr 8, 2026
2995289
Fix https://github.com/getsentry/sentry-dotnet/pull/5111#discussion_r…
jamescrosswell Apr 8, 2026
b75839b
Change locking mechanism on _hasFinished to prevent adding spans to f…
jamescrosswell Apr 9, 2026
b9e4ff1
Renamed file to match the class name
jamescrosswell Apr 9, 2026
7a0395a
Fix threading issues around idleTimer
jamescrosswell Apr 9, 2026
a0778cc
Merge remote-tracking branch 'origin/main' into maui-transactions-5109
jamescrosswell Apr 16, 2026
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 samples/Sentry.Samples.Maui/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public static MauiApp CreateMauiApp()
// but only if tracing is enabled. Here we capture all traces (in a production app you'd probably only
// capture a certain percentage)
options.TracesSampleRate = 1.0F;
options.EnableNavigationTransactions = true;

// Automatically create traces for async relay commands in the MVVM Community Toolkit
options.AddCommunityToolkitIntegration();
Expand Down
4 changes: 4 additions & 0 deletions src/Sentry.Maui/BindableSentryMauiOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ internal class BindableSentryMauiOptions : BindableSentryLoggingOptions
public bool? IncludeBackgroundingStateInBreadcrumbs { get; set; }
public bool? CreateElementEventsBreadcrumbs { get; set; } = false;
public bool? AttachScreenshot { get; set; }
public bool? EnableNavigationTransactions { get; set; }
public TimeSpan? NavigationTransactionIdleTimeout { get; set; }

public void ApplyTo(SentryMauiOptions options)
{
Expand All @@ -19,5 +21,7 @@ public void ApplyTo(SentryMauiOptions options)
options.IncludeBackgroundingStateInBreadcrumbs = IncludeBackgroundingStateInBreadcrumbs ?? options.IncludeBackgroundingStateInBreadcrumbs;
options.CreateElementEventsBreadcrumbs = CreateElementEventsBreadcrumbs ?? options.CreateElementEventsBreadcrumbs;
options.AttachScreenshot = AttachScreenshot ?? options.AttachScreenshot;
options.EnableNavigationTransactions = EnableNavigationTransactions ?? options.EnableNavigationTransactions;
options.NavigationTransactionIdleTimeout = NavigationTransactionIdleTimeout ?? options.NavigationTransactionIdleTimeout;
}
}
93 changes: 88 additions & 5 deletions src/Sentry.Maui/Internal/MauiEventsBinder.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Options;
using Sentry.Internal;

namespace Sentry.Maui.Internal;

Expand All @@ -13,6 +14,10 @@ internal class MauiEventsBinder : IMauiEventsBinder
private readonly SentryMauiOptions _options;
internal readonly IEnumerable<IMauiElementEventBinder> _elementEventBinders;

// Tracks the active auto-finishing navigation transaction so we can explicitly finish it early
// (e.g. when the next navigation begins) before the idle timeout would fire.
private ITransactionTracer? _currentTransaction;
Comment thread
sentry-warden[bot] marked this conversation as resolved.

// https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#breadcrumb-types
// https://github.com/getsentry/sentry/blob/master/static/app/types/breadcrumbs.tsx
internal const string NavigationType = "navigation";
Expand Down Expand Up @@ -319,16 +324,72 @@ internal void HandlePageEvents(Page page, bool bind = true)
}
}

private ITransactionTracer? StartNavigationTransaction(string name)
{
// If there's already a transaction on the scope that we didn't create, it was put there
// manually by the user — don't override it.
var manualTransactionOnScope = false;
_hub.ConfigureScope(scope =>
{
if (scope.Transaction is { } existing && !ReferenceEquals(existing, _currentTransaction))
{
manualTransactionOnScope = true;
}
});
if (manualTransactionOnScope)
{
return null;
}

// Reset the idle timeout instead of creating a new transaction if the destination is the same
if (_currentTransaction is { IsFinished: false } current && current.Name == name)
{
current.ResetIdleTimeout();
return current;
}

// Finish any previous SDK-owned navigation transaction before starting a new one.
_currentTransaction?.Finish(SpanStatus.Ok);

var context = new TransactionContext(name, "ui.load")
{
NameSource = TransactionNameSource.Route
};

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?


_hub.ConfigureScope(static (scope, t) => scope.Transaction = t, transaction);
_currentTransaction = transaction;
return transaction;
}

// Application Events

private void OnApplicationOnPageAppearing(object? sender, Page page) =>
_hub.AddBreadcrumbForEvent(_options, sender, nameof(Application.PageAppearing), NavigationType, NavigationCategory, data => data.AddElementInfo(_options, page, nameof(Page)));
private void OnApplicationOnPageDisappearing(object? sender, Page page) =>
_hub.AddBreadcrumbForEvent(_options, sender, nameof(Application.PageDisappearing), NavigationType, NavigationCategory, data => data.AddElementInfo(_options, page, nameof(Page)));
private void OnApplicationOnModalPushed(object? sender, ModalPushedEventArgs e) =>

private void OnApplicationOnModalPushed(object? sender, ModalPushedEventArgs e)
{
_hub.AddBreadcrumbForEvent(_options, sender, nameof(Application.ModalPushed), NavigationType, NavigationCategory, data => data.AddElementInfo(_options, e.Modal, nameof(e.Modal)));
private void OnApplicationOnModalPopped(object? sender, ModalPoppedEventArgs e) =>
if (_options.EnableNavigationTransactions)
{
StartNavigationTransaction(e.Modal.GetType().Name);
}
}

private void OnApplicationOnModalPopped(object? sender, ModalPoppedEventArgs e)
{
_hub.AddBreadcrumbForEvent(_options, sender, nameof(Application.ModalPopped), NavigationType, NavigationCategory, data => data.AddElementInfo(_options, e.Modal, nameof(e.Modal)));
if (_options.EnableNavigationTransactions)
{
_currentTransaction?.Finish(SpanStatus.Ok);
_currentTransaction = null;
}
Comment thread
jamescrosswell marked this conversation as resolved.
}
private void OnApplicationOnRequestedThemeChanged(object? sender, AppThemeChangedEventArgs e) =>
_hub.AddBreadcrumbForEvent(_options, sender, nameof(Application.RequestedThemeChanged), SystemType, RenderingCategory, data => data.Add(nameof(e.RequestedTheme), e.RequestedTheme.ToString()));

Expand All @@ -340,8 +401,15 @@ private void OnWindowOnActivated(object? sender, EventArgs _) =>
private void OnWindowOnDeactivated(object? sender, EventArgs _) =>
_hub.AddBreadcrumbForEvent(_options, sender, nameof(Window.Deactivated), SystemType, LifecycleCategory);

private void OnWindowOnStopped(object? sender, EventArgs _) =>
private void OnWindowOnStopped(object? sender, EventArgs _)
{
_hub.AddBreadcrumbForEvent(_options, sender, nameof(Window.Stopped), SystemType, LifecycleCategory);
if (_options.EnableNavigationTransactions)
{
_currentTransaction?.Finish(SpanStatus.Ok);
_currentTransaction = null;
}
}

private void OnWindowOnResumed(object? sender, EventArgs _) =>
_hub.AddBreadcrumbForEvent(_options, sender, nameof(Window.Resumed), SystemType, LifecycleCategory);
Expand Down Expand Up @@ -419,22 +487,37 @@ private void OnElementOnUnfocused(object? sender, FocusEventArgs _) =>

// Shell Events

private void OnShellOnNavigating(object? sender, ShellNavigatingEventArgs e) =>
private void OnShellOnNavigating(object? sender, ShellNavigatingEventArgs e)
{
_hub.AddBreadcrumbForEvent(_options, sender, nameof(Shell.Navigating), NavigationType, NavigationCategory, data =>
{
data.Add("from", e.Current?.Location.ToString() ?? "");
data.Add("to", e.Target?.Location.ToString() ?? "");
data.Add(nameof(e.Source), e.Source.ToString());
});

private void OnShellOnNavigated(object? sender, ShellNavigatedEventArgs e) =>
if (_options.EnableNavigationTransactions)
{
StartNavigationTransaction(e.Target?.Location.ToString() ?? "Unknown");
}
}

private void OnShellOnNavigated(object? sender, ShellNavigatedEventArgs e)
{
_hub.AddBreadcrumbForEvent(_options, sender, nameof(Shell.Navigated), NavigationType, NavigationCategory, data =>
{
data.Add("from", e.Previous?.Location.ToString() ?? "");
data.Add("to", e.Current?.Location.ToString() ?? "");
data.Add(nameof(e.Source), e.Source.ToString());
});

// Update the transaction name to the final resolved route now that navigation is confirmed
if (_options.EnableNavigationTransactions && _currentTransaction != null)
{
_currentTransaction.Name = e.Current?.Location.ToString() ?? _currentTransaction.Name;
}
}

// Page Events

private void OnPageOnAppearing(object? sender, EventArgs _) =>
Expand Down
17 changes: 17 additions & 0 deletions src/Sentry.Maui/SentryMauiOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,23 @@ public SentryMauiOptions()
/// </remarks>
public bool AttachScreenshot { get; set; }

/// <summary>
/// Automatically starts a Sentry transaction when the user navigates to a new page and sets it on the scope,
/// allowing child spans (e.g. HTTP requests, database calls) to be attached during page load.
/// The transaction finishes automatically after <see cref="NavigationTransactionIdleTimeout"/> if not
/// finished explicitly first (e.g. by a subsequent navigation).
/// Requires <see cref="SentryOptions.TracesSampleRate"/> or <see cref="SentryOptions.TracesSampler"/> to
/// be configured.
/// The default is <c>true</c>.
/// </summary>
public bool EnableNavigationTransactions { get; set; } = true;

/// <summary>
/// Controls how long an automatic navigation transaction waits before finishing itself when not explicitly
/// finished. Defaults to 3 seconds.
/// </summary>
public TimeSpan NavigationTransactionIdleTimeout { get; set; } = TimeSpan.FromSeconds(3);

private Func<SentryEvent, SentryHint, bool>? _beforeCapture;
/// <summary>
/// Action performed before attaching a screenshot
Expand Down
8 changes: 7 additions & 1 deletion src/Sentry/Extensibility/DisabledHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace Sentry.Extensibility;
/// <summary>
/// Disabled Hub.
/// </summary>
public class DisabledHub : IHub, IDisposable
public class DisabledHub : IHub, IHubInternal, IDisposable
{
/// <summary>
/// The singleton instance.
Expand Down Expand Up @@ -81,6 +81,12 @@ public void UnsetTag(string key)
public ITransactionTracer StartTransaction(ITransactionContext context,
IReadOnlyDictionary<string, object?> customSamplingContext) => NoOpTransaction.Instance;

/// <summary>
/// Returns a dummy transaction.
/// </summary>
public ITransactionTracer StartTransaction(ITransactionContext context, TimeSpan? idleTimeout)
=> NoOpTransaction.Instance;

/// <summary>
/// No-Op.
/// </summary>
Expand Down
10 changes: 9 additions & 1 deletion src/Sentry/Extensibility/HubAdapter.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Sentry.Infrastructure;
using Sentry.Internal;
using Sentry.Protocol.Envelopes;

namespace Sentry.Extensibility;
Expand All @@ -12,7 +13,7 @@ namespace Sentry.Extensibility;
/// </remarks>
/// <inheritdoc cref="IHub" />
[DebuggerStepThrough]
public sealed class HubAdapter : IHub
public sealed class HubAdapter : IHub, IHubInternal
{
/// <summary>
/// The single instance which forwards all calls to <see cref="SentrySdk"/>
Expand Down Expand Up @@ -121,6 +122,13 @@ internal ITransactionTracer StartTransaction(
DynamicSamplingContext? dynamicSamplingContext)
=> SentrySdk.StartTransaction(context, customSamplingContext, dynamicSamplingContext);

/// <summary>
/// Forwards the call to <see cref="SentrySdk"/>.
/// </summary>
[DebuggerStepThrough]
ITransactionTracer IHubInternal.StartTransaction(ITransactionContext context, TimeSpan? idleTimeout)
=> SentrySdk.StartTransaction(context, idleTimeout);
Comment thread
jamescrosswell marked this conversation as resolved.

/// <summary>
/// Forwards the call to <see cref="SentrySdk"/>.
/// </summary>
Expand Down
5 changes: 5 additions & 0 deletions src/Sentry/ITransactionTracer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,9 @@ public interface ITransactionTracer : ITransactionData, ISpan
/// Gets the last active (not finished) span in this transaction.
/// </summary>
public ISpan? GetLastActiveSpan();

/// <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

@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.

Technically this would be a breaking change as well... we could put this (as an internal) on the concrete class rather than the interface. It makes calling the method a bit messy/fragile though.

@Flash0ver what do you think?

}
17 changes: 17 additions & 0 deletions src/Sentry/Infrastructure/ITimer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Sentry.Infrastructure;

/// <summary>
/// Abstraction over a one-shot timer, to allow deterministic testing.
/// </summary>
internal interface ISentryTimer : IDisposable
{
/// <summary>
/// Starts (or restarts) the timer to fire after <paramref name="timeout"/>.
/// </summary>
void Start(TimeSpan timeout);

/// <summary>
/// Cancels any pending fire. Has no effect if the timer is already cancelled.
/// </summary>
void Cancel();
}
22 changes: 22 additions & 0 deletions src/Sentry/Infrastructure/SystemTimer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Sentry.Infrastructure;

/// <summary>
/// Production <see cref="ISentryTimer"/> backed by <see cref="System.Threading.Timer"/>.
/// </summary>
internal sealed class SystemTimer : ISentryTimer
{
private readonly Timer _timer;

public SystemTimer(Action callback)
{
_timer = new Timer(_ => callback(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
}

public void Start(TimeSpan timeout) =>
_timer.Change(timeout, Timeout.InfiniteTimeSpan);

public void Cancel() =>
_timer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);

public void Dispose() => _timer.Dispose();
}
10 changes: 7 additions & 3 deletions src/Sentry/Internal/Hub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

namespace Sentry.Internal;

internal class Hub : IHub, IDisposable
internal class Hub : IHub, IHubInternal, IDisposable
{
private readonly Lock _sessionPauseLock = new();

Expand Down Expand Up @@ -173,10 +173,14 @@ public ITransactionTracer StartTransaction(
IReadOnlyDictionary<string, object?> customSamplingContext)
=> StartTransaction(context, customSamplingContext, null);

public ITransactionTracer StartTransaction(ITransactionContext context, TimeSpan? idleTimeout)
=> StartTransaction(context, new Dictionary<string, object?>(), null, idleTimeout);

internal ITransactionTracer StartTransaction(
ITransactionContext context,
IReadOnlyDictionary<string, object?> customSamplingContext,
DynamicSamplingContext? dynamicSamplingContext)
DynamicSamplingContext? dynamicSamplingContext,
TimeSpan? idleTimeout = null)
{
// If the hub is disabled, we will always sample out. In other words, starting a transaction
// after disposing the hub will result in that transaction not being sent to Sentry.
Expand Down Expand Up @@ -255,7 +259,7 @@ internal ITransactionTracer StartTransaction(
return unsampledTransaction;
}

var transaction = new TransactionTracer(this, context)
var transaction = new TransactionTracer(this, context, idleTimeout)
{
SampleRate = sampleRate,
SampleRand = sampleRand,
Expand Down
15 changes: 15 additions & 0 deletions src/Sentry/Internal/IHubInternal.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Sentry.Internal;

/// <summary>
/// Internal hub interface exposing additional overloads not part of the public <see cref="IHub"/> contract.
/// Implemented by <see cref="Hub"/>, <see cref="Extensibility.DisabledHub"/>, and
/// <see cref="Extensibility.HubAdapter"/>.
/// </summary>
internal interface IHubInternal : IHub
{
/// <summary>
/// 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.

}
2 changes: 2 additions & 0 deletions src/Sentry/Internal/NoOpTransaction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,5 +93,7 @@ public IReadOnlyList<string> Fingerprint

public ISpan? GetLastActiveSpan() => default;

public void ResetIdleTimeout() { }

public void AddBreadcrumb(Breadcrumb breadcrumb) { }
}
10 changes: 10 additions & 0 deletions src/Sentry/SentrySdk.cs
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,16 @@ internal static ITransactionTracer StartTransaction(
DynamicSamplingContext? dynamicSamplingContext)
=> CurrentHub.StartTransaction(context, customSamplingContext, dynamicSamplingContext);

/// <summary>
/// Starts a transaction that will automatically finish after <paramref name="idleTimeout"/> if not
/// finished explicitly first.
/// </summary>
[DebuggerStepThrough]
internal static ITransactionTracer StartTransaction(ITransactionContext context, TimeSpan? idleTimeout)
=> CurrentHub is IHubInternal internalHub
? internalHub.StartTransaction(context, idleTimeout)
: CurrentHub.StartTransaction(context);

/// <summary>
/// Starts a transaction.
/// </summary>
Expand Down
1 change: 1 addition & 0 deletions src/Sentry/SpanTracer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@
{
Status ??= SpanStatus.Ok;
EndTimestamp ??= _stopwatch.CurrentDateTimeOffset;
Transaction?.ChildSpanFinished();

Check warning on line 159 in src/Sentry/SpanTracer.cs

View check run for this annotation

@sentry/warden / warden: code-review

ChildSpanFinished() may be called multiple times when Finish() is invoked on an already-finished span

The `Finish()` method in `SpanTracer` unconditionally calls `Transaction?.ChildSpanFinished()` without checking if the span is already finished. Since `Dispose()` calls `Finish()` and users may call both explicitly or via `using` statements, `ChildSpanFinished()` can be called multiple times for the same span. This incorrectly decrements `_activeSpanCount` multiple times, potentially causing the idle timer to restart prematurely (when `remaining <= 0`) while other child spans are still active, leading to premature transaction completion.

Check warning on line 159 in src/Sentry/SpanTracer.cs

View check run for this annotation

@sentry/warden / warden: find-bugs

Calling Finish() multiple times decrements _activeSpanCount multiple times, causing timer underflow

The new `Transaction?.ChildSpanFinished()` call at line 159 is invoked unconditionally every time `Finish()` is called. Since `Finish()` can be called multiple times (e.g., explicitly followed by `Dispose()`), and since spans over the 1000 limit never increment `_activeSpanCount` but still call `ChildSpanFinished()` when finishing, the active span counter can underflow to negative values. While there's a guard (`_activeSpanCount = 0`), it triggers premature idle timer restarts after every redundant finish call.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Comment thread
jamescrosswell marked this conversation as resolved.
}

/// <inheritdoc />
Expand Down
Loading
Loading