Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions samples/Sentry.Samples.Maui/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ public static MauiApp CreateMauiApp()
// capture a certain percentage)
options.TracesSampleRate = 1.0F;

// Automatically create traces for navigation events
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;
}
}
91 changes: 86 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,70 @@ internal void HandlePageEvents(Page page, bool bind = true)
}
}

private ITransactionTracer? StartNavigationTransaction(string name)
{
// 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?


// Only bind to scope if there is no user-created transaction already there.
var hasUserTransaction = false;
_hub.ConfigureScope(scope =>
{
if (scope.Transaction is { } existing && !ReferenceEquals(existing, _currentTransaction))
{
hasUserTransaction = true;
}
});
if (!hasUserTransaction)
{
_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 +399,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 +485,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/ISentryTimer.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>
public void Start(TimeSpan timeout);

/// <summary>
/// Cancels any pending fire. Has no effect if the timer is already cancelled.
/// </summary>
public 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) { }
}
15 changes: 15 additions & 0 deletions src/Sentry/SentrySdk.cs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,11 @@ public static IDisposable Init(Action<SentryOptions>? configureOptions)

internal static IDisposable UseHub(IHub hub)
{
if (hub is HubAdapter)
{
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).

var oldHub = Interlocked.Exchange(ref CurrentHub, hub);
(oldHub as IDisposable)?.Dispose();
return new DisposeHandle(hub);
Expand Down Expand Up @@ -663,6 +668,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 @@ public void Finish()
{
Status ??= SpanStatus.Ok;
EndTimestamp ??= _stopwatch.CurrentDateTimeOffset;
Transaction?.ChildSpanFinished();
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Comment thread
jamescrosswell marked this conversation as resolved.
}

/// <inheritdoc />
Expand Down
Loading
Loading