diff --git a/.claude/settings.json b/.claude/settings.json
index 13fcf5fd0b..f3cedb0832 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -27,7 +27,8 @@
"WebFetch(domain:docs.sentry.io)",
"WebFetch(domain:develop.sentry.dev)",
"Bash(grep:*)",
- "Bash(mv:*)"
+ "Bash(mv:*)",
+ "Bash(pwsh ./scripts/accept-verifier-changes.ps1)"
],
"deny": []
},
diff --git a/samples/Sentry.Samples.Maui/MauiProgram.cs b/samples/Sentry.Samples.Maui/MauiProgram.cs
index de3abb8c74..ddbd2d9662 100644
--- a/samples/Sentry.Samples.Maui/MauiProgram.cs
+++ b/samples/Sentry.Samples.Maui/MauiProgram.cs
@@ -43,6 +43,9 @@ public static MauiApp CreateMauiApp()
// capture a certain percentage)
options.TracesSampleRate = 1.0F;
+ // Automatically create traces for navigation and user-interaction events
+ options.EnableAutoTransactions = true;
+
// Automatically create traces for async relay commands in the MVVM Community Toolkit
options.AddCommunityToolkitIntegration();
@@ -77,6 +80,7 @@ public static MauiApp CreateMauiApp()
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
+ fonts.AddFont("fa-solid-900.ttf", "FontAwesomeSolid");
});
// Configure Logging, including Structured Logs sent to Sentry (see 'EnableLogs = true')
diff --git a/samples/Sentry.Samples.Maui/Resources/Fonts/fa-solid-900.ttf b/samples/Sentry.Samples.Maui/Resources/Fonts/fa-solid-900.ttf
new file mode 100644
index 0000000000..a0414182dc
Binary files /dev/null and b/samples/Sentry.Samples.Maui/Resources/Fonts/fa-solid-900.ttf differ
diff --git a/samples/Sentry.Samples.Maui/SubmitFeedback.xaml b/samples/Sentry.Samples.Maui/SubmitFeedback.xaml
index f51c08ace5..9387f74f50 100644
--- a/samples/Sentry.Samples.Maui/SubmitFeedback.xaml
+++ b/samples/Sentry.Samples.Maui/SubmitFeedback.xaml
@@ -17,8 +17,16 @@
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityShim.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityShim.cs
index ba60bc907f..4a58027c8f 100644
--- a/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityShim.cs
+++ b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityShim.cs
@@ -24,4 +24,5 @@ internal static class ShimKeys
{
public const string SpanStatus = "sentry.shim.status";
public const string CustomSamplingContext = "sentry.shim.custom_sampling_context";
+ public const string IdleTimeout = "sentry.shim.idle_timeout";
}
diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityTracingExtensions.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityTracingExtensions.cs
new file mode 100644
index 0000000000..1458055a4d
--- /dev/null
+++ b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityTracingExtensions.cs
@@ -0,0 +1,60 @@
+// Alias required because Android TFMs of the core Sentry package otherwise see an ambiguous reference
+// between System.Diagnostics.Activity (global using) and Android.App.Activity (Android implicit usings).
+using Activity = System.Diagnostics.Activity;
+
+namespace Sentry.Internal.Tracing;
+
+///
+/// The API surface Sentry's own integrations use when instrumenting directly with Activities (i.e. without
+/// the ). These helpers cover the Sentry-specific semantics that raw
+/// calls don't express: starting an independent root (transaction), idle
+/// timeouts, rich span statuses.
+///
+internal static class ActivityTracingExtensions
+{
+ ///
+ /// Starts an Activity that is an independent trace root - the Sentry-transaction equivalent.
+ /// Detaches from any ambient Activity first (Sentry semantics: a transaction begins a new trace),
+ /// and optionally carries an idle timeout, which the hands to
+ /// the shadow tracer so the transaction auto-finishes (or is discarded when trivial) after inactivity.
+ ///
+ public static Activity? StartRootActivity(
+ this ActivitySource source, string operation, string name, TimeSpan? idleTimeout = null)
+ {
+ Activity.Current = null;
+
+ var activity = source.CreateActivity(operation, ActivityKind.Internal);
+ if (activity is null)
+ {
+ // No listener is subscribed to this source.
+ return null;
+ }
+
+ activity.DisplayName = name;
+ if (idleTimeout is not null)
+ {
+ activity.SetFused(ShimKeys.IdleTimeout, idleTimeout);
+ }
+
+ activity.Start();
+ return activity;
+ }
+
+ ///
+ /// Resets the idle timeout of a root Activity started with an idle timeout (see
+ /// ), e.g. on user interaction.
+ ///
+ public static void ResetIdleTimeout(this Activity activity) =>
+ (activity.GetFused() as IAutoTimeoutTracer)?.ResetIdleTimeout();
+
+ ///
+ /// Stops the Activity with a rich Sentry - statuses like Cancelled have no
+ /// equivalent and travel via the fused side-channel instead.
+ ///
+ public static void Stop(this Activity activity, SpanStatus status)
+ {
+ activity.SetFused(ShimKeys.SpanStatus, status);
+ activity.SetStatus(status == SpanStatus.Ok ? ActivityStatusCode.Ok : ActivityStatusCode.Error);
+ activity.Stop();
+ }
+}
diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityTransactionShim.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityTransactionShim.cs
index 1e926e4d74..2d113a4f3e 100644
--- a/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityTransactionShim.cs
+++ b/src/Sentry.DiagnosticSource/Internal/Tracing/ActivityTransactionShim.cs
@@ -10,7 +10,7 @@ namespace Sentry.Internal.Tracing;
/// Hub.StartTransaction invokes (via SentryOptions.ActivityShimFactory) for Sentry-API
/// transactions when Activity-based tracing is enabled.
///
-internal sealed class ActivityTransactionShim : ActivitySpanShim, ITransactionTracer
+internal sealed class ActivityTransactionShim : ActivitySpanShim, ITransactionTracer, IAutoTimeoutTracer
{
private readonly ITransactionTracer _inner;
@@ -33,7 +33,8 @@ private ActivityTransactionShim(Activity activity, ITransactionTracer inner, IHu
IHub hub,
ITransactionContext context,
IReadOnlyDictionary customSamplingContext,
- DynamicSamplingContext? dynamicSamplingContext)
+ DynamicSamplingContext? dynamicSamplingContext,
+ TimeSpan? idleTimeout)
{
// Continue an inbound (remote) trace, propagating the upstream sampling decision.
ActivityContext parentContext = default;
@@ -44,16 +45,27 @@ private ActivityTransactionShim(Activity activity, ITransactionTracer inner, IHu
context.TraceId.AsActivityTraceId(), parentSpanId.AsActivitySpanId(), flags, isRemote: true);
}
+ // Sentry semantics: StartTransaction begins a new trace (unless explicitly continuing a remote one).
+ // Detach from any ambient Activity so the new activity does not become an implicit child of it -
+ // e.g. a second UI click must be an independent transaction, not a child of the (still idling) first.
+ var ambientActivity = Activity.Current;
+ Activity.Current = null;
+
var activity = SentryActivitySources.Shim.CreateActivity(
context.Operation, ActivityKind.Internal, parentContext);
if (activity is null)
{
// No listener is subscribed to the Sentry ActivitySource.
+ Activity.Current = ambientActivity;
return null;
}
activity.DisplayName = context.Name;
activity.SetFused(ShimKeys.CustomSamplingContext, customSamplingContext);
+ if (idleTimeout is not null)
+ {
+ activity.SetFused(ShimKeys.IdleTimeout, idleTimeout);
+ }
if (dynamicSamplingContext is not null)
{
activity.SetFused(dynamicSamplingContext);
@@ -65,10 +77,15 @@ private ActivityTransactionShim(Activity activity, ITransactionTracer inner, IHu
{
// The listener saw the activity but did not map it (e.g. the hub was disabled mid-flight).
activity.Dispose();
+ Activity.Current = ambientActivity;
return null;
}
var shim = new ActivityTransactionShim(activity, inner, hub);
+
+ // Note: out-of-band tracer finishes (idle timer) stop the backing Activity via the OnFinished
+ // hook the processor installs on every activity-derived transaction.
+
// The processor put the shadow tracer on the scope; replace it with the shim so that
// integrations calling hub.GetSpan()/scope.Transaction route child spans through Activities.
hub.ConfigureScope(static (scope, s) => scope.Transaction = s, shim);
@@ -167,6 +184,8 @@ public string? TransactionName
public SdkVersion Sdk => _inner.Sdk;
+ void IAutoTimeoutTracer.ResetIdleTimeout() => (_inner as IAutoTimeoutTracer)?.ResetIdleTimeout();
+
public string? Platform
{
get => _inner.Platform;
diff --git a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs
index 28119226ca..70404ff541 100644
--- a/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs
+++ b/src/Sentry.DiagnosticSource/Internal/Tracing/SentryActivityProcessor.cs
@@ -1,10 +1,9 @@
// Alias required because Android TFMs of the core Sentry package otherwise see an ambiguous reference
// between System.Diagnostics.Activity (global using) and Android.App.Activity (Android implicit usings).
-using Activity = System.Diagnostics.Activity;
-
using Sentry.Extensibility;
using Sentry.Internal.Extensions;
using Sentry.Internal.OpenTelemetry;
+using Activity = System.Diagnostics.Activity;
namespace Sentry.Internal.Tracing;
@@ -171,13 +170,25 @@ private void CreateRootSpan(Activity data)
var baggageHeader = data.Baggage.AsBaggageHeader();
var dynamicSamplingContext = data.GetFused()
?? baggageHeader.CreateDynamicSamplingContext(_replaySession);
+ var idleTimeout = data.GetFused(ShimKeys.IdleTimeout);
var transaction = _hub.StartTransaction(
- transactionContext, customSamplingContext, dynamicSamplingContext
+ transactionContext, customSamplingContext, dynamicSamplingContext, idleTimeout
);
if (transaction is TransactionTracer tracer)
{
tracer.Contexts.Trace.Origin = OpenTelemetryOrigin;
tracer.StartTimestamp = data.StartTimeUtc;
+
+ // If the tracer finishes out-of-band (e.g. its idle timer fires, capturing or discarding
+ // without the Activity lifecycle being involved), the backing Activity must be stopped too -
+ // otherwise it leaks and stays Activity.Current, silently re-parenting later spans.
+ tracer.OnFinished = () =>
+ {
+ if (!data.IsStopped)
+ {
+ data.Stop();
+ }
+ };
}
_hub.ConfigureScope(static (scope, transaction) => scope.Transaction = transaction, transaction);
transaction.SetFused(data);
diff --git a/src/Sentry.Maui/BindableSentryMauiOptions.cs b/src/Sentry.Maui/BindableSentryMauiOptions.cs
index f4f98e1ad0..104f0d4623 100644
--- a/src/Sentry.Maui/BindableSentryMauiOptions.cs
+++ b/src/Sentry.Maui/BindableSentryMauiOptions.cs
@@ -10,6 +10,9 @@ internal class BindableSentryMauiOptions : BindableSentryLoggingOptions
public bool? IncludeBackgroundingStateInBreadcrumbs { get; set; }
public bool? CreateElementEventsBreadcrumbs { get; set; } = false;
public bool? AttachScreenshot { get; set; }
+ public bool? EnableAutoTransactions { get; set; }
+ public TimeSpan? AutoTransactionIdleTimeout { get; set; }
+ public bool? EnableUserInteractionTracing { get; set; }
public void ApplyTo(SentryMauiOptions options)
{
@@ -19,5 +22,8 @@ public void ApplyTo(SentryMauiOptions options)
options.IncludeBackgroundingStateInBreadcrumbs = IncludeBackgroundingStateInBreadcrumbs ?? options.IncludeBackgroundingStateInBreadcrumbs;
options.CreateElementEventsBreadcrumbs = CreateElementEventsBreadcrumbs ?? options.CreateElementEventsBreadcrumbs;
options.AttachScreenshot = AttachScreenshot ?? options.AttachScreenshot;
+ options.EnableAutoTransactions = EnableAutoTransactions ?? options.EnableAutoTransactions;
+ options.AutoTransactionIdleTimeout = AutoTransactionIdleTimeout ?? options.AutoTransactionIdleTimeout;
+ options.EnableUserInteractionTracing = EnableUserInteractionTracing ?? options.EnableUserInteractionTracing;
}
}
diff --git a/src/Sentry.Maui/Internal/Extensions.cs b/src/Sentry.Maui/Internal/Extensions.cs
index 87ac1f03b8..257831437b 100644
--- a/src/Sentry.Maui/Internal/Extensions.cs
+++ b/src/Sentry.Maui/Internal/Extensions.cs
@@ -82,4 +82,20 @@ public static string ToStringOrTypeName(this object o)
var s = o.ToString();
return s == null || s == t.FullName ? t.Name : s;
}
+
+ internal static Page? FindContainingPage(this Element? element)
+ {
+ const int hopLimit = 32;
+ var hops = 0;
+ var current = element;
+ while (current is not null && hops++ < hopLimit)
+ {
+ if (current is Page page)
+ {
+ return page;
+ }
+ current = current.Parent;
+ }
+ return null;
+ }
}
diff --git a/src/Sentry.Maui/Internal/MauiEventsBinder.cs b/src/Sentry.Maui/Internal/MauiEventsBinder.cs
index 78e619bed9..90f5b50a8b 100644
--- a/src/Sentry.Maui/Internal/MauiEventsBinder.cs
+++ b/src/Sentry.Maui/Internal/MauiEventsBinder.cs
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Options;
+using Sentry.Internal;
namespace Sentry.Maui.Internal;
@@ -13,6 +14,12 @@ internal class MauiEventsBinder : IMauiEventsBinder
private readonly SentryMauiOptions _options;
internal readonly IEnumerable _elementEventBinders;
+ // Tracks the active auto-finishing navigation transaction so we can reset the idle timeout as appropriate
+ internal ITransactionTracer? CurrentUiTx { get; private set; }
+
+ // Tracks the navigation child span when navigation is nested under a click transaction.
+ internal ISpan? CurrentNavSpan { get; private set; }
+
// 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";
@@ -22,6 +29,7 @@ internal class MauiEventsBinder : IMauiEventsBinder
internal const string NavigationCategory = "navigation";
internal const string RenderingCategory = "ui.rendering";
internal const string UserActionCategory = "ui.useraction";
+ internal const string UserInteractionClickOp = "ui.action.click";
public MauiEventsBinder(IHub hub, IOptions options, IEnumerable elementEventBinders)
{
@@ -123,6 +131,19 @@ internal void OnApplicationOnDescendantAdded(object? _, ElementEventArgs e)
}
break;
}
+
+ if (_options is { EnableAutoTransactions: true, EnableUserInteractionTracing: true })
+ {
+ switch (e.Element)
+ {
+ case Button button:
+ button.Pressed += OnElementPressed;
+ break;
+ case ImageButton imageButton:
+ imageButton.Pressed += OnElementPressed;
+ break;
+ }
+ }
}
internal void OnBreadcrumbCreateCallback(BreadcrumbEvent breadcrumb)
@@ -176,6 +197,16 @@ internal void OnApplicationOnDescendantRemoved(object? _, ElementEventArgs e)
}
break;
}
+
+ switch (e.Element)
+ {
+ case Button button:
+ button.Pressed -= OnElementPressed;
+ break;
+ case ImageButton imageButton:
+ imageButton.Pressed -= OnElementPressed;
+ break;
+ }
}
internal void HandleWindowEvents(Window window, bool bind = true)
@@ -319,14 +350,101 @@ internal void HandlePageEvents(Page page, bool bind = true)
}
}
+ internal ISpan? StartNavigationSpan(string name)
+ {
+ // Nav events only ever get captured as child spans
+ if (_hub.GetSpan() is not { IsFinished: false } parentSpan)
+ {
+ return null;
+ }
+
+ if (CurrentUiTx is IAutoTimeoutTracer autoTimeoutTracer)
+ {
+ autoTimeoutTracer.ResetIdleTimeout();
+ }
+
+ CurrentNavSpan?.Finish(SpanStatus.Ok);
+ CurrentNavSpan = parentSpan.StartChild("ui.load", name);
+ return CurrentNavSpan;
+ }
+
+ private void FinishNavigationSpan()
+ {
+ if (CurrentNavSpan is not { IsFinished: false } navSpan)
+ {
+ return;
+ }
+
+ navSpan.Finish(SpanStatus.Ok);
+ CurrentNavSpan = null;
+ }
+
+ private void OnElementPressed(object? sender, EventArgs _)
+ {
+ if (sender is not Element element)
+ {
+ return;
+ }
+
+ string? identifier = null;
+ if (!string.IsNullOrEmpty(element.AutomationId))
+ {
+ identifier = element.AutomationId;
+ }
+ else if (!string.IsNullOrEmpty(element.StyleId))
+ {
+ identifier = element.StyleId;
+ }
+ else
+ {
+ _options.DiagnosticLogger?.Log(
+ SentryLevel.Warning,
+ "Click transaction skipped: element has no AutomationId or StyleId");
+ return;
+ }
+
+ var pageName = element.FindContainingPage()?.GetType().Name;
+ var name = pageName != null ? $"{pageName}.{identifier}" : identifier;
+ StartUiTransaction(name);
+ }
+
+ internal void StartUiTransaction(string name)
+ {
+ // Each UI interaction is a separate tx... we don't want separate button clicks grouped as a single tx.
+ if (CurrentNavSpan is { IsFinished: false })
+ {
+ CurrentNavSpan.Finish(SpanStatus.Cancelled);
+ CurrentNavSpan = null;
+ }
+
+ if (CurrentUiTx is not null)
+ {
+ // Idle timer will clean up any previous UI transaction, but we don't want any more child spans on it
+ _hub.ConfigureScope(static (scope, tx) => scope.ResetTransaction(tx), CurrentUiTx);
+ }
+
+ var context = new TransactionContext(name, UserInteractionClickOp)
+ {
+ NameSource = TransactionNameSource.Component
+ };
+ CurrentUiTx = _hub is IHubInternal internalHub
+ ? internalHub.StartTransaction(context, _options.AutoTransactionIdleTimeout)
+ : _hub.StartTransaction(context);
+
+ // Bind to scope only if no other transaction is already there (user-installed or SDK-owned navigation).
+ _hub.ConfigureScope(static (scope, tx) => scope.Transaction ??= tx, CurrentUiTx);
+ }
+
// 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) =>
_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) =>
_hub.AddBreadcrumbForEvent(_options, sender, nameof(Application.ModalPopped), NavigationType, NavigationCategory, data => data.AddElementInfo(_options, e.Modal, nameof(e.Modal)));
private void OnApplicationOnRequestedThemeChanged(object? sender, AppThemeChangedEventArgs e) =>
@@ -340,8 +458,30 @@ 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.EnableAutoTransactions)
+ {
+ CurrentNavSpan?.Finish(SpanStatus.Ok);
+ CurrentNavSpan = null;
+ if (CurrentUiTx is { IsFinished: false } uiTx)
+ {
+ if (uiTx.Spans.Count > 0)
+ {
+ // Only finish UI transactions with child spans.
+ // Childless transactions get discarded by the idle timeout.
+ uiTx.Finish(SpanStatus.Ok);
+ }
+ else
+ {
+ // No child spans, so just detach from scope and let idle timeout handle it
+ _hub.ConfigureScope(static (scope, tx) => scope.ResetTransaction(tx), uiTx);
+ }
+ }
+ CurrentUiTx = null;
+ }
+ }
private void OnWindowOnResumed(object? sender, EventArgs _) =>
_hub.AddBreadcrumbForEvent(_options, sender, nameof(Window.Resumed), SystemType, LifecycleCategory);
@@ -379,8 +519,15 @@ private void OnWindowOnModalPushed(object? sender, ModalPushedEventArgs e) =>
private void OnWindowOnModalPopped(object? sender, ModalPoppedEventArgs e) =>
_hub.AddBreadcrumbForEvent(_options, sender, nameof(Window.ModalPopped), NavigationType, NavigationCategory, data => data.AddElementInfo(_options, e.Modal, nameof(e.Modal)));
- private void OnWindowOnPopCanceled(object? sender, EventArgs _) =>
+ private void OnWindowOnPopCanceled(object? sender, EventArgs _)
+ {
_hub.AddBreadcrumbForEvent(_options, sender, nameof(Window.PopCanceled), NavigationType, NavigationCategory);
+ if (_options.EnableAutoTransactions && CurrentNavSpan is { IsFinished: false } navSpan)
+ {
+ navSpan.Finish(SpanStatus.Cancelled);
+ CurrentNavSpan = null;
+ }
+ }
// Element Events
@@ -419,7 +566,8 @@ 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() ?? "");
@@ -427,7 +575,14 @@ private void OnShellOnNavigating(object? sender, ShellNavigatingEventArgs e) =>
data.Add(nameof(e.Source), e.Source.ToString());
});
- private void OnShellOnNavigated(object? sender, ShellNavigatedEventArgs e) =>
+ if (_options.EnableAutoTransactions)
+ {
+ StartNavigationSpan(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() ?? "");
@@ -435,6 +590,18 @@ private void OnShellOnNavigated(object? sender, ShellNavigatedEventArgs e) =>
data.Add(nameof(e.Source), e.Source.ToString());
});
+ if (_options.EnableAutoTransactions)
+ {
+ // Update to the final resolved route now that navigation is confirmed
+ if (e.Current?.Location.ToString() is { } resovedRoute)
+ {
+ CurrentNavSpan?.Description = resovedRoute;
+ }
+
+ FinishNavigationSpan();
+ }
+ }
+
// Page Events
private void OnPageOnAppearing(object? sender, EventArgs _) =>
diff --git a/src/Sentry.Maui/Internal/MauiUiActivityTracing.cs b/src/Sentry.Maui/Internal/MauiUiActivityTracing.cs
new file mode 100644
index 0000000000..63c2d96935
--- /dev/null
+++ b/src/Sentry.Maui/Internal/MauiUiActivityTracing.cs
@@ -0,0 +1,72 @@
+using Sentry.Internal.Tracing;
+
+namespace Sentry.Maui.Internal;
+
+///
+/// Spike (Q2): the direct-Activity equivalent of the tracing concerns in
+/// (StartUiTransaction / StartNavigationSpan), showing what that code becomes once the shim methods are
+/// replaced with Activity instrumentation. Compare side by side:
+///
+/// // shim (today): // direct (this class):
+/// internalHub.StartTransaction(context, idleTimeout) Source.StartRootActivity(op, name, idleTimeout)
+/// autoTimeoutTracer.ResetIdleTimeout() CurrentUiActivity.ResetIdleTimeout()
+/// parentSpan.StartChild("ui.load", name) Source.StartActivity("ui.load") + DisplayName
+/// navSpan.Finish(SpanStatus.Ok) CurrentNavActivity.Stop(SpanStatus.Ok)
+///
+/// The idle mechanics (timer, pause-while-children-active, discard-if-empty, end-time trimming) are
+/// unchanged - they run on the shadow tracer, driven by the idle timeout fused onto the root Activity.
+///
+internal class MauiUiActivityTracing
+{
+ // Note: ShouldListenTo callbacks fire while this static initializer is still running (the runtime
+ // announces new ActivitySources to registered listeners inside the ActivitySource constructor), so
+ // listener filters must compare by name (the const below) rather than by reference to this field.
+ internal const string SourceName = "Sentry.Maui";
+ internal static readonly ActivitySource Source = new(SourceName);
+
+ private readonly TimeSpan _idleTimeout;
+
+ internal Activity? CurrentUiActivity { get; private set; }
+ internal Activity? CurrentNavActivity { get; private set; }
+
+ public MauiUiActivityTracing(TimeSpan idleTimeout)
+ {
+ _idleTimeout = idleTimeout;
+ }
+
+ public void StartUiTransaction(string name)
+ {
+ // Each UI interaction is a separate transaction... we don't want separate button clicks grouped
+ // as a single one. Any in-flight navigation span belongs to the previous interaction.
+ if (CurrentNavActivity is { IsStopped: false } navActivity)
+ {
+ navActivity.Stop(SpanStatus.Cancelled);
+ CurrentNavActivity = null;
+ }
+
+ // The previous UI transaction (if any) is left to idle out; StartRootActivity detaches from it,
+ // so nothing new can parent under it from this flow.
+ CurrentUiActivity = Source.StartRootActivity(
+ MauiEventsBinder.UserInteractionClickOp, name, _idleTimeout);
+ }
+
+ public Activity? StartNavigationSpan(string name)
+ {
+ // Nav events only ever get captured as child spans.
+ if (CurrentUiActivity is not { IsStopped: false } uiActivity)
+ {
+ return null;
+ }
+
+ uiActivity.ResetIdleTimeout();
+
+ CurrentNavActivity?.Stop(SpanStatus.Ok);
+ CurrentNavActivity = Source.StartActivity(
+ "ui.load", ActivityKind.Internal, uiActivity.Context);
+ if (CurrentNavActivity is not null)
+ {
+ CurrentNavActivity.DisplayName = name;
+ }
+ return CurrentNavActivity;
+ }
+}
diff --git a/src/Sentry.Maui/SentryMauiOptions.cs b/src/Sentry.Maui/SentryMauiOptions.cs
index 17038bf747..2214498475 100644
--- a/src/Sentry.Maui/SentryMauiOptions.cs
+++ b/src/Sentry.Maui/SentryMauiOptions.cs
@@ -76,6 +76,32 @@ public SentryMauiOptions()
///
public bool AttachScreenshot { get; set; }
+ ///
+ /// Determines whethec spans are created automatically for App navigation events. Set EnableAutoTransactions to
+ /// false if you only want to track navigation breadcrumbs.
+ ///
+ ///
+ /// The default is true.
+ ///
+ public bool EnableAutoTransactions { get; set; } = true;
+
+ ///
+ /// Controls how long an automatic transaction (navigation or user interaction) waits before finishing
+ /// itself when not explicitly finished. Defaults to 3 seconds.
+ ///
+ public TimeSpan AutoTransactionIdleTimeout { get; set; } = TimeSpan.FromSeconds(3);
+
+ ///
+ /// Automatically starts a Sentry transaction for user-interaction events (currently: Button clicks).
+ /// Requires to be enabled as well as
+ /// or to be
+ /// configured. Interaction transactions are named <PageType>.<AutomationId|StyleId>.
+ /// If the element has neither nor the
+ /// transaction is skipped and a warning is logged.
+ /// The default is true.
+ ///
+ public bool EnableUserInteractionTracing { get; set; } = true;
+
private Func? _beforeCapture;
///
/// Action performed before attaching a screenshot
diff --git a/src/Sentry/Extensibility/DisabledHub.cs b/src/Sentry/Extensibility/DisabledHub.cs
index 9bf79277f0..4649efc3b4 100644
--- a/src/Sentry/Extensibility/DisabledHub.cs
+++ b/src/Sentry/Extensibility/DisabledHub.cs
@@ -6,7 +6,7 @@ namespace Sentry.Extensibility;
///
/// Disabled Hub.
///
-public class DisabledHub : IHub, IDisposable
+public class DisabledHub : IHub, IHubInternal, IDisposable
{
///
/// The singleton instance.
@@ -81,6 +81,12 @@ public void UnsetTag(string key)
public ITransactionTracer StartTransaction(ITransactionContext context,
IReadOnlyDictionary customSamplingContext) => NoOpTransaction.Instance;
+ ///
+ /// Returns a dummy transaction.
+ ///
+ ITransactionTracer IHubInternal.StartTransaction(ITransactionContext context, TimeSpan? idleTimeout)
+ => NoOpTransaction.Instance;
+
///
/// No-Op.
///
diff --git a/src/Sentry/Extensibility/HubAdapter.cs b/src/Sentry/Extensibility/HubAdapter.cs
index 055498c2fc..5bca0defe5 100644
--- a/src/Sentry/Extensibility/HubAdapter.cs
+++ b/src/Sentry/Extensibility/HubAdapter.cs
@@ -1,4 +1,5 @@
using Sentry.Infrastructure;
+using Sentry.Internal;
using Sentry.Protocol.Envelopes;
namespace Sentry.Extensibility;
@@ -12,7 +13,7 @@ namespace Sentry.Extensibility;
///
///
[DebuggerStepThrough]
-public sealed class HubAdapter : IHub
+public sealed class HubAdapter : IHub, IHubInternal
{
///
/// The single instance which forwards all calls to
@@ -121,6 +122,13 @@ internal ITransactionTracer StartTransaction(
DynamicSamplingContext? dynamicSamplingContext)
=> SentrySdk.StartTransaction(context, customSamplingContext, dynamicSamplingContext);
+ ///
+ /// Forwards the call to .
+ ///
+ [DebuggerStepThrough]
+ ITransactionTracer IHubInternal.StartTransaction(ITransactionContext context, TimeSpan? idleTimeout)
+ => SentrySdk.StartTransaction(context, idleTimeout);
+
///
/// Forwards the call to .
///
diff --git a/src/Sentry/HubExtensions.cs b/src/Sentry/HubExtensions.cs
index 07639f4eea..2a5ed8796c 100644
--- a/src/Sentry/HubExtensions.cs
+++ b/src/Sentry/HubExtensions.cs
@@ -251,9 +251,10 @@ internal static ITransactionTracer StartTransaction(
this IHub hub,
ITransactionContext context,
IReadOnlyDictionary customSamplingContext,
- DynamicSamplingContext? dynamicSamplingContext) => hub switch
+ DynamicSamplingContext? dynamicSamplingContext,
+ TimeSpan? idleTimeout = null) => hub switch
{
- Hub fullHub => fullHub.StartTransaction(context, customSamplingContext, dynamicSamplingContext),
+ Hub fullHub => fullHub.StartTransaction(context, customSamplingContext, dynamicSamplingContext, idleTimeout),
HubAdapter adapter => adapter.StartTransaction(context, customSamplingContext, dynamicSamplingContext),
_ => hub.StartTransaction(context, customSamplingContext)
};
diff --git a/src/Sentry/Infrastructure/ISentryTimer.cs b/src/Sentry/Infrastructure/ISentryTimer.cs
new file mode 100644
index 0000000000..516ba57ca2
--- /dev/null
+++ b/src/Sentry/Infrastructure/ISentryTimer.cs
@@ -0,0 +1,17 @@
+namespace Sentry.Infrastructure;
+
+///
+/// Abstraction over a one-shot timer, to allow deterministic testing.
+///
+internal interface ISentryTimer : IDisposable
+{
+ ///
+ /// Starts (or restarts) the timer to fire after .
+ ///
+ public void Start(TimeSpan timeout);
+
+ ///
+ /// Cancels any pending fire. Has no effect if the timer is already cancelled.
+ ///
+ public void Cancel();
+}
diff --git a/src/Sentry/Infrastructure/SystemTimer.cs b/src/Sentry/Infrastructure/SystemTimer.cs
new file mode 100644
index 0000000000..3d66ca3043
--- /dev/null
+++ b/src/Sentry/Infrastructure/SystemTimer.cs
@@ -0,0 +1,22 @@
+namespace Sentry.Infrastructure;
+
+///
+/// Production backed by .
+///
+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();
+}
diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs
index bffc26cc4c..f84ced3c16 100644
--- a/src/Sentry/Internal/Hub.cs
+++ b/src/Sentry/Internal/Hub.cs
@@ -6,7 +6,7 @@
namespace Sentry.Internal;
-internal class Hub : IHub, IDisposable
+internal class Hub : IHub, IHubInternal, IDisposable
{
private readonly Lock _sessionPauseLock = new();
@@ -173,10 +173,14 @@ public ITransactionTracer StartTransaction(
IReadOnlyDictionary customSamplingContext)
=> StartTransaction(context, customSamplingContext, null);
+ public ITransactionTracer StartTransaction(ITransactionContext context, TimeSpan? idleTimeout)
+ => StartTransaction(context, new Dictionary(), null, idleTimeout);
+
internal ITransactionTracer StartTransaction(
ITransactionContext context,
IReadOnlyDictionary 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.
@@ -198,7 +202,7 @@ internal ITransactionTracer StartTransaction(
// shim avoids infinite recursion). A null return means no listener is running - fall through too.
if (_options.ActivityShimFactory is { } shimFactory
&& context is not TransactionContext { Instrumenter: Instrumenter.OpenTelemetry }
- && shimFactory(this, context, customSamplingContext, dynamicSamplingContext) is { } shimmedTransaction)
+ && shimFactory(this, context, customSamplingContext, dynamicSamplingContext, idleTimeout) is { } shimmedTransaction)
{
return shimmedTransaction;
}
@@ -273,7 +277,7 @@ internal ITransactionTracer StartTransaction(
return unsampledTransaction;
}
- var transaction = new TransactionTracer(this, context)
+ var transaction = new TransactionTracer(this, context, idleTimeout, _options.IdleTimerFactory)
{
SampleRate = sampleRate,
SampleRand = sampleRand,
diff --git a/src/Sentry/Internal/IAutoTimeoutTracer.cs b/src/Sentry/Internal/IAutoTimeoutTracer.cs
new file mode 100644
index 0000000000..1058ef714e
--- /dev/null
+++ b/src/Sentry/Internal/IAutoTimeoutTracer.cs
@@ -0,0 +1,14 @@
+namespace Sentry.Internal;
+
+///
+/// Internal interface for transactions that support auto-timeout reset. Added as internal to prevent a breaking change.
+/// We could make this public in the next major release (although it is really an implementation depail so internal
+/// isn't that bad).
+///
+internal interface IAutoTimeoutTracer
+{
+ ///
+ /// Resets the idle timeout for auto-finishing transactions.
+ ///
+ public void ResetIdleTimeout();
+}
diff --git a/src/Sentry/Internal/IHubInternal.cs b/src/Sentry/Internal/IHubInternal.cs
new file mode 100644
index 0000000000..67d11b770a
--- /dev/null
+++ b/src/Sentry/Internal/IHubInternal.cs
@@ -0,0 +1,15 @@
+namespace Sentry.Internal;
+
+///
+/// Internal hub interface exposing additional overloads not part of the public contract.
+/// Implemented by , , and
+/// .
+///
+internal interface IHubInternal : IHub
+{
+ ///
+ /// Starts a transaction that will automatically finish after if not
+ /// finished explicitly first.
+ ///
+ public ITransactionTracer StartTransaction(ITransactionContext context, TimeSpan? idleTimeout);
+}
diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs
index 5b6e6b459a..627414eab6 100644
--- a/src/Sentry/SentryOptions.cs
+++ b/src/Sentry/SentryOptions.cs
@@ -1248,7 +1248,13 @@ public StackTraceMode StackTraceMode
/// declared here in core - which compiles for TFMs without ActivityListener support - while the shim
/// implementation ships in Sentry.DiagnosticSource for those TFMs.
///
- internal Func, DynamicSamplingContext?, ITransactionTracer?>? ActivityShimFactory { get; set; }
+ internal Func, DynamicSamplingContext?, TimeSpan?, ITransactionTracer?>? ActivityShimFactory { get; set; }
+
+ ///
+ /// Spike: allows tests to substitute a deterministic timer for idle-timeout transactions created through
+ /// the real Hub (TransactionTracer's timerFactory parameter is otherwise only reachable via its ctor).
+ ///
+ internal Func? IdleTimerFactory { get; set; }
///
/// During the transition period to OTLP we give SDK users the option to keep using Sentry's tracing in conjunction
diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs
index a6e31c57af..667dd46cdf 100644
--- a/src/Sentry/SentrySdk.cs
+++ b/src/Sentry/SentrySdk.cs
@@ -222,6 +222,11 @@ public static IDisposable Init(Action? 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;
+ }
var oldHub = Interlocked.Exchange(ref CurrentHub, hub);
(oldHub as IDisposable)?.Dispose();
return new DisposeHandle(hub);
@@ -675,6 +680,16 @@ internal static ITransactionTracer StartTransaction(
DynamicSamplingContext? dynamicSamplingContext)
=> CurrentHub.StartTransaction(context, customSamplingContext, dynamicSamplingContext);
+ ///
+ /// Starts a transaction that will automatically finish after if not
+ /// finished explicitly first.
+ ///
+ [DebuggerStepThrough]
+ internal static ITransactionTracer StartTransaction(ITransactionContext context, TimeSpan? idleTimeout)
+ => CurrentHub is IHubInternal internalHub
+ ? internalHub.StartTransaction(context, idleTimeout)
+ : CurrentHub.StartTransaction(context);
+
///
/// Starts a transaction.
///
diff --git a/src/Sentry/SpanTracer.cs b/src/Sentry/SpanTracer.cs
index b221918694..d79cb1a41b 100644
--- a/src/Sentry/SpanTracer.cs
+++ b/src/Sentry/SpanTracer.cs
@@ -34,11 +34,32 @@ public sealed class SpanTracer : IBaseTracer, ISpan
///
public DateTimeOffset StartTimestamp { get; internal set; }
+ private DateTimeOffset? _endTimestamp;
+ private bool _isFinished;
+
///
- public DateTimeOffset? EndTimestamp { get; internal set; }
+ public DateTimeOffset? EndTimestamp
+ {
+ get => Volatile.Read(ref _isFinished) ? _endTimestamp : null;
+ internal set
+ {
+ // Ordering is load-bearing: the gate-flip is release-ordered against the data write,
+ // so lock-free readers gating on `_isFinished` see a consistent (timestamp, finished) pair.
+ if (value is null)
+ {
+ Volatile.Write(ref _isFinished, false);
+ _endTimestamp = null;
+ }
+ else
+ {
+ _endTimestamp = value;
+ Volatile.Write(ref _isFinished, true);
+ }
+ }
+ }
///
- public bool IsFinished => EndTimestamp is not null;
+ public bool IsFinished => Volatile.Read(ref _isFinished);
// Not readonly because of deserialization
internal Dictionary? InternalMeasurements { get; private set; }
@@ -156,6 +177,7 @@ public void Finish()
{
Status ??= SpanStatus.Ok;
EndTimestamp ??= _stopwatch.CurrentDateTimeOffset;
+ Transaction?.ChildSpanFinished();
}
///
diff --git a/src/Sentry/TransactionTracer.cs b/src/Sentry/TransactionTracer.cs
index e4ce907607..76fb05021a 100644
--- a/src/Sentry/TransactionTracer.cs
+++ b/src/Sentry/TransactionTracer.cs
@@ -1,4 +1,5 @@
using Sentry.Extensibility;
+using Sentry.Infrastructure;
using Sentry.Internal;
using Sentry.Protocol;
@@ -7,15 +8,30 @@ namespace Sentry;
///
/// Transaction tracer.
///
-public sealed class TransactionTracer : IBaseTracer, ITransactionTracer
+public sealed class TransactionTracer : IBaseTracer, IAutoTimeoutTracer, ITransactionTracer
{
+ private const int SpanLimit = 1000;
+
private readonly IHub _hub;
private readonly SentryOptions? _options;
- private readonly Timer? _idleTimer;
+ private readonly ISentryTimer? _idleTimer;
+
+ ///
+ /// Spike: invoked once the transaction transitions to finished (captured or discarded), including
+ /// out-of-band finishes from the idle timer. Used by the Activity tracing shim to stop the backing
+ /// Activity when the tracer finishes without the Activity lifecycle being involved.
+ ///
+ internal Action? OnFinished { get; set; }
+ private readonly TimeSpan? _idleTimeout;
private readonly SentryStopwatch _stopwatch = SentryStopwatch.StartNew();
- private InterlockedBoolean _hasFinished;
- private InterlockedBoolean _cancelIdleTimeout;
+ ///
+ /// Set exactly once inside the `_lock` at the same time as setting `_endTimestamp` and disposing the idle timer.
+ /// `IsFinished` makes a volatile read of `_hasFinished` (no lock required as would be necessary for the more
+ /// complex `_endTimestamp` struct), which is essentially why we need this separate flag.
+ ///
+ private bool _hasFinished;
+ private readonly Lock _lock = new();
private readonly Instrumenter _instrumenter = Instrumenter.Sentry;
@@ -66,8 +82,24 @@ public SentryId TraceId
///
public DateTimeOffset StartTimestamp { get; internal set; }
+ ///
+ /// Guard writes with `_lock`
+ ///
+ private DateTimeOffset? _endTimestamp;
+
///
- public DateTimeOffset? EndTimestamp { get; internal set; }
+ public DateTimeOffset? EndTimestamp
+ {
+ // get => _endTimestamp;
+ get => Volatile.Read(ref _hasFinished) ? _endTimestamp : null;
+ internal set
+ {
+ lock (_lock)
+ {
+ _endTimestamp = value;
+ }
+ }
+ }
///
public string Operation
@@ -180,7 +212,7 @@ public IReadOnlyList Fingerprint
internal bool HasMetrics => _metricsSummary.IsValueCreated;
///
- public bool IsFinished => EndTimestamp is not null;
+ public bool IsFinished => Volatile.Read(ref _hasFinished);
internal DynamicSamplingContext? DynamicSamplingContext { get; set; }
@@ -219,7 +251,8 @@ internal TransactionTracer(IHub hub, string name, string operation, TransactionN
///
/// Initializes an instance of .
///
- internal TransactionTracer(IHub hub, ITransactionContext context, TimeSpan? idleTimeout = null)
+ internal TransactionTracer(IHub hub, ITransactionContext context, TimeSpan? idleTimeout = null,
+ Func? timerFactory = null)
{
_hub = hub;
_options = _hub.GetSentryOptions();
@@ -239,24 +272,39 @@ internal TransactionTracer(IHub hub, ITransactionContext context, TimeSpan? idle
Origin = transactionContext.Origin;
}
- // Set idle timer only if an idle timeout has been provided directly
if (idleTimeout.HasValue)
{
- _cancelIdleTimeout = true; // Timer will be cancelled once, atomically setting this back to false
- _idleTimer = new Timer(state =>
- {
- if (state is not TransactionTracer transactionTracer)
- {
- _options?.LogDebug(
- $"Idle timeout callback received nor non-TransactionTracer state. " +
- "Unable to finish transaction automatically."
- );
- return;
- }
+ _idleTimeout = idleTimeout;
+ var factory = timerFactory ?? (cb => new SystemTimer(cb));
+ _idleTimer = factory(OnIdleTimeout);
+ _idleTimer.Start(idleTimeout.Value);
+ }
+ }
+
+ private void OnIdleTimeout()
+ {
+ if (IsSentryRequest)
+ {
+ _options?.LogDebug("Transaction '{0}' is a Sentry Request. Don't complete.", SpanId);
+ return;
+ }
+
+ if (!TryBeginFinish(fromIdleTimer: true, out var shouldDiscard))
+ {
+ return;
+ }
- transactionTracer.Finish(Status ?? SpanStatus.Ok);
- }, this, idleTimeout.Value, Timeout.InfiniteTimeSpan);
+ if (shouldDiscard)
+ {
+ _options?.LogDebug("Idle transaction '{0}' has no child spans. Discarding.", SpanId);
+ _hub.ConfigureScope(static (scope, tracer) => scope.ResetTransaction(tracer), this);
+ OnFinished?.Invoke();
+ return;
}
+
+ Status ??= SpanStatus.Ok;
+ CompleteCapture();
+ OnFinished?.Invoke();
}
///
@@ -296,91 +344,200 @@ internal ISpan StartChild(SpanId? spanId, SpanId parentSpanId, string operation,
private void AddChildSpan(SpanTracer span)
{
- // Limit spans to 1000
- var isOutOfLimit = _spans.Count >= 1000;
- span.IsSampled = isOutOfLimit ? false : IsSampled;
-
- if (!isOutOfLimit)
+ lock (_lock)
{
+ if (_hasFinished)
+ {
+ _options?.LogDebug("Discarding child span '{0}' as the trace has already finished", SpanId);
+ span.IsSampled = false;
+ return;
+ }
+
+ if (_spans.Count >= SpanLimit)
+ {
+ _options?.LogDebug("Discarding child span '{0}' due to {1} span limit", SpanId, SpanLimit);
+ span.IsSampled = false;
+ return;
+ }
+
+ span.IsSampled = IsSampled;
+ _idleTimer?.Cancel();
_spans.Add(span);
_activeSpanTracker.Push(span);
}
}
- private class LastActiveSpanTracker
+ internal void ChildSpanFinished()
{
- private readonly Lock _lock = new();
+ // Fast path: only idle-timeout transactions need to react to child finishes.
+ // `_idleTimeout` is readonly, so this check is safe outside the lock
+ if (!_idleTimeout.HasValue)
+ {
+ return;
+ }
+ lock (_lock)
+ {
+ if (_hasFinished)
+ {
+ return;
+ }
+
+ // Only restart the idle timer when there are no more active (unfinished) child spans
+ if (_activeSpanTracker.PeekActive() == null)
+ {
+ _idleTimer?.Start(_idleTimeout.Value);
+ }
+ }
+ }
+ private class LastActiveSpanTracker
+ {
private readonly Lazy> _trackedSpans = new();
private Stack TrackedSpans => _trackedSpans.Value;
- public void Push(ISpan span)
+ public void Push(ISpan span) => TrackedSpans.Push(span);
+
+ public ISpan? PeekActive()
{
- lock (_lock)
+ // Non-destructive: leave finished spans in place so SpanTracer.Unfinish() can resurrect them.
+ foreach (var span in TrackedSpans)
{
- TrackedSpans.Push(span);
+ if (!span.IsFinished)
+ {
+ return span;
+ }
}
+ return null;
}
- public ISpan? PeekActive()
+ public void Clear() => TrackedSpans.Clear();
+ }
+ private readonly LastActiveSpanTracker _activeSpanTracker = new LastActiveSpanTracker();
+
+ ///
+ public ISpan? GetLastActiveSpan()
+ {
+ lock (_lock)
{
- lock (_lock)
+ return _activeSpanTracker.PeekActive();
+ }
+ }
+
+ void IAutoTimeoutTracer.ResetIdleTimeout()
+ {
+ lock (_lock)
+ {
+ if (!_idleTimeout.HasValue || _hasFinished)
+ {
+ return;
+ }
+ _idleTimer?.Start(_idleTimeout.Value);
+ }
+ }
+
+ ///
+ /// The single atomic primitive for transitioning Running -> Finished. Returns true to
+ /// exactly one caller; all subsequent calls return false. The Running -> Finished flip,
+ /// the `EndTimestamp` write, and the timer disposal all happen inside the same critical
+ /// section, so no observer can ever see an inconsistent intermediate state.
+ ///
+ ///
+ /// True when called from the idle-timer callback. In that case we also re-check whether
+ /// a child span is still active (it might have been added after the timer fired but
+ /// before we acquired the lock); if so, the firing is stale, and we refuse to finish.
+ ///
+ ///
+ /// True if the transaction had no child spans and the caller (idle timer) should
+ /// discard rather than capture.
+ ///
+ private bool TryBeginFinish(bool fromIdleTimer, out bool shouldDiscard)
+ {
+ shouldDiscard = false;
+ lock (_lock)
+ {
+ if (_hasFinished)
+ {
+ return false;
+ }
+
+ if (fromIdleTimer)
+ {
+ // Defensive re-check: if a child span arrived between the timer firing and
+ // us acquiring the lock, AddChildSpan's `Cancel()` may have failed to stop
+ // the in-flight callback. The active-span check inside the lock is the
+ // authoritative answer.
+ if (_activeSpanTracker.PeekActive() != null)
+ {
+ return false;
+ }
+
+ if (_spans.IsEmpty)
+ {
+ shouldDiscard = true;
+ }
+ }
+
+ // Compute the end timestamp inside the lock. For idle transactions, trim to
+ // the latest finished child span when available. (Scanning `_spans` here is
+ // bounded by SpanLimit and only runs once per transaction.)
+ DateTimeOffset endTimestamp;
+ if (_idleTimeout.HasValue && !shouldDiscard)
{
- while (TrackedSpans.Count > 0)
+ DateTimeOffset latest = default;
+ foreach (var s in _spans)
{
- // Stop tracking inactive spans
- var span = TrackedSpans.Peek();
- if (!span.IsFinished)
+ if (s.IsFinished && s.EndTimestamp is { } et && et > latest)
{
- return span;
+ latest = et;
}
- TrackedSpans.Pop();
}
- return null;
+ endTimestamp = latest == default ? _stopwatch.CurrentDateTimeOffset : latest;
}
- }
-
- public void Clear()
- {
- lock (_lock)
+ else
{
- TrackedSpans.Clear();
+ endTimestamp = _endTimestamp ?? _stopwatch.CurrentDateTimeOffset;
}
+
+ _endTimestamp = endTimestamp;
+ _idleTimer?.Cancel();
+ _idleTimer?.Dispose();
+
+ // MUST be the last write inside the lock for all logic that depends on volatile reads to work
+ Volatile.Write(ref _hasFinished, true);
+ return true;
}
}
- private readonly LastActiveSpanTracker _activeSpanTracker = new LastActiveSpanTracker();
-
- ///
- public ISpan? GetLastActiveSpan() => _activeSpanTracker.PeekActive();
///
public void Finish()
{
- if (_hasFinished.Exchange(true))
+ if (!TryBeginFinish(fromIdleTimer: false, out _))
{
return;
}
_options?.LogDebug("Attempting to finish Transaction '{0}'.", SpanId);
- if (_cancelIdleTimeout.Exchange(false) == true)
- {
- _options?.LogDebug("Disposing of idle timer for Transaction '{0}'.", SpanId);
- _idleTimer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
- _idleTimer?.Dispose();
- }
-
if (IsSentryRequest)
{
// Normally we wouldn't start transactions for Sentry requests but when instrumenting with OpenTelemetry
// we are only able to determine whether it's a sentry request or not when closing a span... we leave these
- // to be garbage collected and we don't want idle timers triggering on them
+ // to be garbage collected. The idle timer has already been disposed inside TryBeginFinish.
_options?.LogDebug("Transaction '{0}' is a Sentry Request. Don't complete.", SpanId);
return;
}
- TransactionProfiler?.Finish();
Status ??= SpanStatus.Ok;
- EndTimestamp ??= _stopwatch.CurrentDateTimeOffset;
+ CompleteCapture();
+ OnFinished?.Invoke();
+ }
+
+ ///
+ /// Performs non-locked work that follows a successful Running -> Finished transition
+ ///
+ private void CompleteCapture()
+ {
+ TransactionProfiler?.Finish();
+
_options?.LogDebug("Finished Transaction '{0}'.", SpanId);
// Clear the transaction from the scope and regenerate the Propagation Context
diff --git a/test/Sentry.Maui.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Maui.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt
index db0645d89c..b6a2149087 100644
--- a/test/Sentry.Maui.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt
+++ b/test/Sentry.Maui.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt
@@ -38,7 +38,10 @@ namespace Sentry.Maui
{
public SentryMauiOptions() { }
public bool AttachScreenshot { get; set; }
+ public System.TimeSpan AutoTransactionIdleTimeout { get; set; }
public bool CreateElementEventsBreadcrumbs { get; set; }
+ public bool EnableAutoTransactions { get; set; }
+ public bool EnableUserInteractionTracing { get; set; }
public bool IncludeBackgroundingStateInBreadcrumbs { get; set; }
public bool IncludeTextInBreadcrumbs { get; set; }
public bool IncludeTitleInBreadcrumbs { get; set; }
diff --git a/test/Sentry.Maui.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Maui.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt
index f2790100c2..31c7c4ae29 100644
--- a/test/Sentry.Maui.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt
+++ b/test/Sentry.Maui.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt
@@ -31,7 +31,10 @@ namespace Sentry.Maui
{
public SentryMauiOptions() { }
public bool AttachScreenshot { get; set; }
+ public System.TimeSpan AutoTransactionIdleTimeout { get; set; }
public bool CreateElementEventsBreadcrumbs { get; set; }
+ public bool EnableAutoTransactions { get; set; }
+ public bool EnableUserInteractionTracing { get; set; }
public bool IncludeBackgroundingStateInBreadcrumbs { get; set; }
public bool IncludeTextInBreadcrumbs { get; set; }
public bool IncludeTitleInBreadcrumbs { get; set; }
diff --git a/test/Sentry.Maui.Tests/MauiActivityShimTests.cs b/test/Sentry.Maui.Tests/MauiActivityShimTests.cs
new file mode 100644
index 0000000000..7b16fe09c1
--- /dev/null
+++ b/test/Sentry.Maui.Tests/MauiActivityShimTests.cs
@@ -0,0 +1,190 @@
+using Sentry.Internal;
+using Sentry.Internal.Tracing;
+using Sentry.Maui.Internal;
+using Sentry.Testing;
+
+namespace Sentry.Maui.Tests;
+
+///
+/// Battle-tests the Activity tracing shim against the MAUI automatic trace instrumentation from #5138:
+/// the real drives the real Hub with the shim installed, and the idle
+/// transaction mechanics (idle timeout, reset-on-interaction, discard-if-empty, end-time trimming) must
+/// all work while every transaction/span is backed by a System.Diagnostics.Activity.
+///
+public class MauiActivityShimTests : IDisposable
+{
+ private readonly SentryMauiOptions _options;
+ private readonly ISentryClient _client;
+ private readonly Hub _hub;
+ private readonly SentryActivityListener _listener;
+ private readonly MauiEventsBinder _binder;
+ private readonly List _timers = new();
+
+ public MauiActivityShimTests()
+ {
+ _options = new SentryMauiOptions
+ {
+ Dsn = ValidDsn,
+ TracesSampleRate = 1.0,
+ AutoSessionTracking = false,
+ Instrumenter = Instrumenter.OpenTelemetry,
+ AutoTransactionIdleTimeout = TimeSpan.FromSeconds(3)
+ };
+ _options.IdleTimerFactory = callback =>
+ {
+ var timer = new MockTimer(callback);
+ _timers.Add(timer);
+ return timer;
+ };
+ _client = Substitute.For();
+ _hub = new Hub(_options, _client);
+ _listener = new SentryActivityListener(_hub, s => s.Name == SentryActivitySources.ShimSourceName);
+ _binder = new MauiEventsBinder(
+ _hub,
+ Microsoft.Extensions.Options.Options.Create(_options),
+ []);
+ }
+
+ public void Dispose()
+ {
+ _listener.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ [Fact]
+ public void StartUiTransaction_ShimEnabled_CreatesActivityBackedIdleTransaction()
+ {
+ // Act
+ _binder.StartUiTransaction("MainPage.loginButton");
+
+ // Assert
+ using (new AssertionScope())
+ {
+ // The binder went through IHubInternal.StartTransaction(context, idleTimeout) and got a shim back.
+ _binder.CurrentUiTx.Should().BeOfType();
+
+ var activity = Activity.Current;
+ activity.Should().NotBeNull();
+ activity!.OperationName.Should().Be(MauiEventsBinder.UserInteractionClickOp);
+ activity.DisplayName.Should().Be("MainPage.loginButton");
+ activity.Source.Name.Should().Be(SentryActivitySources.ShimSourceName);
+
+ // The idle timeout survived the trip through the Activity side-channel: the shadow tracer
+ // created by the processor is running a real idle timer with the configured timeout.
+ _timers.Should().HaveCount(1);
+ _timers[0].LastTimeout.Should().Be(_options.AutoTransactionIdleTimeout);
+ _timers[0].IsCancelled.Should().BeFalse();
+ }
+
+ _binder.CurrentUiTx!.Finish();
+ }
+
+ [Fact]
+ public void IdleTimeout_WithChildSpan_CapturesTrimmedTransaction_AndStopsActivity()
+ {
+ // Arrange
+ _binder.StartUiTransaction("MainPage.loginButton");
+ var rootActivity = Activity.Current!;
+
+ // A navigation span goes through hub.GetSpan() -> scope -> shim, so it is Activity-backed too.
+ // StartNavigationSpan also calls ResetIdleTimeout through the shim's IAutoTimeoutTracer.
+ var navSpan = _binder.StartNavigationSpan("LoginPage")!;
+ navSpan.Should().BeOfType();
+ navSpan.Finish(SpanStatus.Ok);
+
+ // Act: the idle timeout elapses.
+ _timers.Single().Fire();
+
+ // Assert
+ using (new AssertionScope())
+ {
+ _client.Received(1).CaptureTransaction(
+ Arg.Is(t =>
+ t.Name == "MainPage.loginButton" &&
+ t.Operation == MauiEventsBinder.UserInteractionClickOp &&
+ t.Spans.Count == 1 &&
+ t.Spans.Single().Operation == "ui.load" &&
+ t.Spans.Single().Description == "LoginPage" &&
+ // Idle transactions trim their end time to the last finished child span.
+ t.EndTimestamp == t.Spans.Single().EndTimestamp),
+ Arg.Any(),
+ Arg.Any());
+
+ // The out-of-band (timer-driven) finish stopped the backing Activity, so it cannot leak or
+ // stay Activity.Current and re-parent later spans.
+ rootActivity.IsStopped.Should().BeTrue();
+ Activity.Current.Should().BeNull();
+ }
+ }
+
+ [Fact]
+ public void StartNavigationSpan_ResetsIdleTimeout_ThroughShim()
+ {
+ // Arrange
+ _binder.StartUiTransaction("MainPage.loginButton");
+ var timer = _timers.Single();
+ var startsBefore = timer.StartCount;
+
+ // Act: the binder casts the transaction to IAutoTimeoutTracer - the shim must pass this through.
+ _binder.StartNavigationSpan("LoginPage");
+
+ // Assert
+ timer.StartCount.Should().BeGreaterThan(startsBefore);
+
+ _binder.CurrentNavSpan!.Finish();
+ _binder.CurrentUiTx!.Finish();
+ }
+
+ [Fact]
+ public void IdleTimeout_NoChildSpans_DiscardsTransaction_AndStopsActivity()
+ {
+ // Arrange
+ _binder.StartUiTransaction("MainPage.loginButton");
+ var rootActivity = Activity.Current!;
+
+ // Act: idle timeout elapses with no child spans - the transaction is trivial and gets discarded.
+ _timers.Single().Fire();
+
+ // Assert
+ using (new AssertionScope())
+ {
+ _client.DidNotReceive().CaptureTransaction(
+ Arg.Any(), Arg.Any(), Arg.Any());
+ rootActivity.IsStopped.Should().BeTrue();
+ _hub.GetTransaction().Should().BeNull();
+ }
+ }
+
+ [Fact]
+ public void SecondClick_StartsIndependentTransaction()
+ {
+ // Arrange: first click, transaction still idling (not finished).
+ _binder.StartUiTransaction("MainPage.loginButton");
+ var firstActivity = Activity.Current!;
+ var firstTraceId = _binder.CurrentUiTx!.TraceId;
+
+ // Act: second click while the first transaction is still current.
+ _binder.StartUiTransaction("MainPage.logoutButton");
+ var secondActivity = Activity.Current!;
+
+ // Assert: the second transaction is an independent root (a new trace), NOT an implicit child of
+ // the first activity - the shim explicitly detaches from the ambient Activity.
+ using (new AssertionScope())
+ {
+ secondActivity.Should().NotBeSameAs(firstActivity);
+ secondActivity.Parent.Should().BeNull();
+ secondActivity.ParentSpanId.Should().Be(default(ActivitySpanId));
+ _binder.CurrentUiTx.TraceId.Should().NotBe(firstTraceId);
+ _timers.Should().HaveCount(2);
+ }
+
+ // Both idle out: the first is discarded (no children), the second too - and both activities stop.
+ _timers[0].Fire();
+ _timers[1].Fire();
+ using (new AssertionScope())
+ {
+ firstActivity.IsStopped.Should().BeTrue();
+ secondActivity.IsStopped.Should().BeTrue();
+ }
+ }
+}
diff --git a/test/Sentry.Maui.Tests/MauiDirectActivityTests.cs b/test/Sentry.Maui.Tests/MauiDirectActivityTests.cs
new file mode 100644
index 0000000000..a216574117
--- /dev/null
+++ b/test/Sentry.Maui.Tests/MauiDirectActivityTests.cs
@@ -0,0 +1,179 @@
+using Sentry.Internal;
+using Sentry.Internal.Tracing;
+using Sentry.Maui.Internal;
+using Sentry.Testing;
+
+namespace Sentry.Maui.Tests;
+
+///
+/// Spike (Q2): the same behavioral scenarios as , but driven through
+/// - the direct-Activity implementation with no shim, no
+/// ITransactionTracer and no IHubInternal. Identical assertions passing against both implementations is
+/// the evidence that removing the shim from the MAUI integration is a mechanical conversion.
+///
+public class MauiDirectActivityTests : IDisposable
+{
+ private static readonly TimeSpan IdleTimeout = TimeSpan.FromSeconds(3);
+
+ private readonly SentryOptions _options;
+ private readonly ISentryClient _client;
+ private readonly Hub _hub;
+ private readonly SentryActivityListener _listener;
+ private readonly MauiUiActivityTracing _tracing;
+ private readonly List _timers = new();
+
+ public MauiDirectActivityTests()
+ {
+ _options = new SentryOptions
+ {
+ Dsn = ValidDsn,
+ TracesSampleRate = 1.0,
+ AutoSessionTracking = false,
+ Instrumenter = Instrumenter.OpenTelemetry
+ };
+ _options.IdleTimerFactory = callback =>
+ {
+ var timer = new MockTimer(callback);
+ _timers.Add(timer);
+ return timer;
+ };
+ _client = Substitute.For();
+ _hub = new Hub(_options, _client);
+ // Filter by name: comparing to MauiUiActivityTracing.Source by reference races its static
+ // initialization (ShouldListenTo fires from inside the ActivitySource constructor).
+ _listener = new SentryActivityListener(_hub, s => s.Name == MauiUiActivityTracing.SourceName);
+ _tracing = new MauiUiActivityTracing(IdleTimeout);
+ }
+
+ public void Dispose()
+ {
+ _listener.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ [Fact]
+ public void StartUiTransaction_CreatesIdleRootActivity()
+ {
+ // Act
+ _tracing.StartUiTransaction("MainPage.loginButton");
+
+ // Assert
+ using (new AssertionScope())
+ {
+ var activity = _tracing.CurrentUiActivity;
+ activity.Should().NotBeNull();
+ activity!.OperationName.Should().Be(MauiEventsBinder.UserInteractionClickOp);
+ activity.DisplayName.Should().Be("MainPage.loginButton");
+ Activity.Current.Should().BeSameAs(activity);
+
+ // The idle timeout fused onto the Activity reached the shadow tracer.
+ _timers.Should().HaveCount(1);
+ _timers[0].LastTimeout.Should().Be(IdleTimeout);
+ _timers[0].IsCancelled.Should().BeFalse();
+ }
+
+ _tracing.CurrentUiActivity!.Stop();
+ }
+
+ [Fact]
+ public void IdleTimeout_WithChildSpan_CapturesTrimmedTransaction_AndStopsActivity()
+ {
+ // Arrange
+ _tracing.StartUiTransaction("MainPage.loginButton");
+ var rootActivity = _tracing.CurrentUiActivity!;
+
+ var navActivity = _tracing.StartNavigationSpan("LoginPage")!;
+ navActivity.Stop(SpanStatus.Ok);
+
+ // Act: the idle timeout elapses.
+ _timers.Single().Fire();
+
+ // Assert
+ using (new AssertionScope())
+ {
+ _client.Received(1).CaptureTransaction(
+ Arg.Is(t =>
+ t.Name == "MainPage.loginButton" &&
+ t.Operation == MauiEventsBinder.UserInteractionClickOp &&
+ t.Spans.Count == 1 &&
+ t.Spans.Single().Operation == "ui.load" &&
+ t.Spans.Single().Description == "LoginPage" &&
+ // Idle transactions trim their end time to the last finished child span.
+ t.EndTimestamp == t.Spans.Single().EndTimestamp),
+ Arg.Any(),
+ Arg.Any());
+
+ rootActivity.IsStopped.Should().BeTrue();
+ }
+ }
+
+ [Fact]
+ public void StartNavigationSpan_ResetsIdleTimeout()
+ {
+ // Arrange
+ _tracing.StartUiTransaction("MainPage.loginButton");
+ var timer = _timers.Single();
+ var startsBefore = timer.StartCount;
+
+ // Act
+ _tracing.StartNavigationSpan("LoginPage");
+
+ // Assert
+ timer.StartCount.Should().BeGreaterThan(startsBefore);
+
+ _tracing.CurrentNavActivity!.Stop();
+ _tracing.CurrentUiActivity!.Stop();
+ }
+
+ [Fact]
+ public void IdleTimeout_NoChildSpans_DiscardsTransaction_AndStopsActivity()
+ {
+ // Arrange
+ _tracing.StartUiTransaction("MainPage.loginButton");
+ var rootActivity = _tracing.CurrentUiActivity!;
+
+ // Act: idle timeout elapses with no child spans - the transaction is trivial and gets discarded.
+ _timers.Single().Fire();
+
+ // Assert
+ using (new AssertionScope())
+ {
+ _client.DidNotReceive().CaptureTransaction(
+ Arg.Any(), Arg.Any(), Arg.Any());
+ rootActivity.IsStopped.Should().BeTrue();
+ _hub.GetTransaction().Should().BeNull();
+ }
+ }
+
+ [Fact]
+ public void SecondClick_StartsIndependentTransaction()
+ {
+ // Arrange: first click, transaction still idling (not finished).
+ _tracing.StartUiTransaction("MainPage.loginButton");
+ var firstActivity = _tracing.CurrentUiActivity!;
+
+ // Act: second click while the first transaction is still current.
+ _tracing.StartUiTransaction("MainPage.logoutButton");
+ var secondActivity = _tracing.CurrentUiActivity!;
+
+ // Assert: the second transaction is an independent root (a new trace), NOT an implicit child of
+ // the first activity - StartRootActivity explicitly detaches from the ambient Activity.
+ using (new AssertionScope())
+ {
+ secondActivity.Should().NotBeSameAs(firstActivity);
+ secondActivity.Parent.Should().BeNull();
+ secondActivity.ParentSpanId.Should().Be(default(ActivitySpanId));
+ secondActivity.TraceId.Should().NotBe(firstActivity.TraceId);
+ _timers.Should().HaveCount(2);
+ }
+
+ // Both idle out: both are discarded (no children) and both activities stop.
+ _timers[0].Fire();
+ _timers[1].Fire();
+ using (new AssertionScope())
+ {
+ firstActivity.IsStopped.Should().BeTrue();
+ secondActivity.IsStopped.Should().BeTrue();
+ }
+ }
+}
diff --git a/test/Sentry.Maui.Tests/MauiEventsBinderFixture.cs b/test/Sentry.Maui.Tests/MauiEventsBinderFixture.cs
index 38cb143a7a..b45ab996e0 100644
--- a/test/Sentry.Maui.Tests/MauiEventsBinderFixture.cs
+++ b/test/Sentry.Maui.Tests/MauiEventsBinderFixture.cs
@@ -1,10 +1,11 @@
+using Sentry.Internal;
using Sentry.Maui.Internal;
namespace Sentry.Maui.Tests;
internal class MauiEventsBinderFixture
{
- public IHub Hub { get; }
+ public IHubInternal Hub { get; }
public MauiEventsBinder Binder { get; }
@@ -14,11 +15,9 @@ internal class MauiEventsBinderFixture
public MauiEventsBinderFixture(params IEnumerable elementEventBinders)
{
- Hub = Substitute.For();
+ Hub = Substitute.For();
Hub.SubstituteConfigureScope(Scope);
- Scope.Transaction = Substitute.For();
-
Options.Debug = true;
var logger = Substitute.For();
logger.IsEnabled(Arg.Any()).Returns(true);
diff --git a/test/Sentry.Maui.Tests/MauiEventsBinderTests.Button.cs b/test/Sentry.Maui.Tests/MauiEventsBinderTests.Button.cs
new file mode 100644
index 0000000000..bb081cc1ed
--- /dev/null
+++ b/test/Sentry.Maui.Tests/MauiEventsBinderTests.Button.cs
@@ -0,0 +1,347 @@
+using Sentry.Maui.Internal;
+
+namespace Sentry.Maui.Tests;
+
+public partial class MauiEventsBinderTests
+{
+ [Fact]
+ public void Button_Pressed_StartsTransactionWithClickOp()
+ {
+ // Arrange
+ var button = new Button { AutomationId = "my-btn" };
+ _fixture.Binder.OnApplicationOnDescendantAdded(null, new ElementEventArgs(button));
+ _fixture.Hub.StartTransaction(Arg.Any(), Arg.Any())
+ .Returns(Substitute.For());
+
+ // Act
+ button.RaiseEvent(nameof(Button.Pressed), EventArgs.Empty);
+
+ // Assert
+ _fixture.Hub.Received(1).StartTransaction(
+ Arg.Is(c => c.Operation == MauiEventsBinder.UserInteractionClickOp),
+ Arg.Any());
+ }
+
+ [Fact]
+ public void Button_Pressed_UsesAutomationIdAsIdentifier()
+ {
+ // Arrange
+ var button = new Button { AutomationId = "my-btn", StyleId = "styleId-ignored" };
+ _fixture.Binder.OnApplicationOnDescendantAdded(null, new ElementEventArgs(button));
+ _fixture.Hub.StartTransaction(Arg.Any(), Arg.Any())
+ .Returns(Substitute.For());
+
+ // Act
+ button.RaiseEvent(nameof(Button.Pressed), EventArgs.Empty);
+
+ // Assert - AutomationId wins over StyleId
+ _fixture.Hub.Received(1).StartTransaction(
+ Arg.Is(c => c.Name == "my-btn"),
+ Arg.Any());
+ }
+
+ [Fact]
+ public void Button_Pressed_FallsBackToStyleIdWhenNoAutomationId()
+ {
+ // Arrange
+ var button = new Button { StyleId = "my-btn" };
+ _fixture.Binder.OnApplicationOnDescendantAdded(null, new ElementEventArgs(button));
+ _fixture.Hub.StartTransaction(Arg.Any(), Arg.Any())
+ .Returns(Substitute.For());
+
+ // Act
+ button.RaiseEvent(nameof(Button.Pressed), EventArgs.Empty);
+
+ // Assert
+ _fixture.Hub.Received(1).StartTransaction(
+ Arg.Is(c => c.Name == "my-btn"),
+ Arg.Any());
+ }
+
+ [Fact]
+ public void Button_Pressed_NoAutomationIdOrStyleId_DoesNotStartTransaction()
+ {
+ // Arrange
+ var button = new Button();
+ _fixture.Binder.OnApplicationOnDescendantAdded(null, new ElementEventArgs(button));
+
+ // Act
+ button.RaiseEvent(nameof(Button.Pressed), EventArgs.Empty);
+
+ // Assert
+ _fixture.Hub.DidNotReceive().StartTransaction(Arg.Any(), Arg.Any());
+ _fixture.Options.DiagnosticLogger!.Received(1).Log(
+ SentryLevel.Warning,
+ Arg.Is(m => m.Contains("AutomationId") && m.Contains("StyleId") && m.Contains("Click transaction skipped")),
+ Arg.Any(),
+ Arg.Any