Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,11 @@ private void RelayCommandOnPropertyChanged(object? sender, PropertyChangedEventA
{
// Note that we may be creating a transaction here and if so we explicitly don't store it on
// Scope.Transaction, because Scope.Transaction is AsyncLocal<T> and MAUI Apps have a global scope. The
// results would be that we would store the transaction on the scope, but it would never be cleared again,
// result would be that we would store the transaction on the scope, but it would never be cleared again,
// since the next call to OnPropertyChanged for this RelayCommand will (likely) be from a different thread.
var span = hub.StartSpan(SpanName, SpanOp);
if (span is ITransactionTracer transaction)
{
hub.ConfigureScope(scope => scope.Transaction = transaction);
}

// We pass autoSetScopeTransaction: false so that this holds even when the user has globally enabled
// SentryOptions.AutoSetScopeTransactions.
var span = hub.StartSpan(SpanName, SpanOp, autoSetScopeTransaction: false);
relay.SetFused(span);
}
else if (relay.GetFused<ISpan>() is { } span)
Expand Down
2 changes: 2 additions & 0 deletions src/Sentry/BindableSentryOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ internal partial class BindableSentryOptions
public bool? IsGlobalModeEnabled { get; set; }
public bool? EnableScopeSync { get; set; }
public bool? EnableBackpressureHandling { get; set; }
public bool? AutoSetScopeTransactions { get; set; }
public List<string>? TagFilters { get; set; }
public bool? SendDefaultPii { get; set; }
public bool? IsEnvironmentUser { get; set; }
Expand Down Expand Up @@ -65,6 +66,7 @@ public void ApplyTo(SentryOptions options)
options.IsGlobalModeEnabled = IsGlobalModeEnabled ?? options.IsGlobalModeEnabled;
options.EnableScopeSync = EnableScopeSync ?? options.EnableScopeSync;
options.EnableBackpressureHandling = EnableBackpressureHandling ?? options.EnableBackpressureHandling;
options.AutoSetScopeTransactions = AutoSetScopeTransactions ?? options.AutoSetScopeTransactions;
options.TagFilters = TagFilters?.Select(s => new StringOrRegex(s)).ToList() ?? options.TagFilters;
options.SendDefaultPii = SendDefaultPii ?? options.SendDefaultPii;
options.IsEnvironmentUser = IsEnvironmentUser ?? options.IsEnvironmentUser;
Expand Down
33 changes: 29 additions & 4 deletions src/Sentry/HubExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,36 @@ public static ITransactionTracer StartTransaction(
/// <summary>
/// Starts a span or transaction if a transaction is not already active on the scope
/// </summary>
public static ISpan StartSpan(this IHub hub, string operation, string description)
public static ISpan StartSpan(this IHub hub, string operation, string description) =>
hub.StartSpan(operation, description, null);

/// <summary>
/// Starts a span or transaction if a transaction is not already active on the scope.
/// <paramref name="autoSetScopeTransaction"/> overrides <see cref="SentryOptions.AutoSetScopeTransactions"/> for a
/// newly started transaction (it has no effect when a child span is started off an existing transaction). This lets
/// integrations control whether a transaction is stored on the scope independently of the user's global setting -
/// e.g. the MAUI CommunityToolkit binder opts out because the scope is global and AsyncLocal-backed.
/// </summary>
internal static ISpan StartSpan(this IHub hub, string operation, string description, bool? autoSetScopeTransaction)
{
return hub.GetTransaction() is { } transaction
? transaction.StartChild(operation, description)
: hub.StartTransaction(operation, description); // this is actually in the wrong order but changing it may break other things
if (hub.GetTransaction() is { } transaction)
{
return transaction.StartChild(operation, description);
}

// No active transaction - start a new one. Note the operation/description argument order below is intentionally
// preserved from the original (arguably wrong) behaviour of StartSpan; changing it may break other things.
if (hub is Hub fullHub)
{
return fullHub.StartTransaction(
new TransactionContext(operation, description),
new Dictionary<string, object?>(),
null,
autoSetScopeTransaction);
}

// Fallback for non-Hub IHub implementations, which can't honour the override.
return hub.StartTransaction(operation, description);
}

/// <summary>
Expand Down
20 changes: 19 additions & 1 deletion src/Sentry/Internal/Hub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@ public ITransactionTracer StartTransaction(
internal ITransactionTracer StartTransaction(
ITransactionContext context,
IReadOnlyDictionary<string, object?> customSamplingContext,
DynamicSamplingContext? dynamicSamplingContext)
DynamicSamplingContext? dynamicSamplingContext,
bool? autoSetScopeTransaction = 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 @@ -258,6 +259,7 @@ internal ITransactionTracer StartTransaction(
// If no DSC was provided, create one based on this transaction.
// Must be done AFTER the sampling decision has been made (the DSC propagates sampling decisions).
unsampledTransaction.DynamicSamplingContext ??= unsampledTransaction.CreateDynamicSamplingContext(_options, _replaySession);
TryAutoSetScopeTransaction(unsampledTransaction, autoSetScopeTransaction);
return unsampledTransaction;
}

Expand All @@ -280,9 +282,25 @@ internal ITransactionTracer StartTransaction(

// A sampled out transaction still appears fully functional to the user
// but will be dropped by the client and won't reach Sentry's servers.
TryAutoSetScopeTransaction(transaction, autoSetScopeTransaction);
return transaction;
}

private void TryAutoSetScopeTransaction(ITransactionTracer transaction, bool? autoSetScopeTransaction)
{
// The per-call override (used by some integrations) takes precedence over the global option.
if (!(autoSetScopeTransaction ?? _options.AutoSetScopeTransactions))
{
return;
}

// Only set the transaction on the scope if there isn't already one set (manually by the user or by another
// integration). We never overwrite an existing transaction. There's no need to track whether we were the one
// to set it for clearing purposes: TransactionTracer.Finish calls Scope.ResetTransaction, which only clears
// the scope's transaction if it still references this exact instance.
ConfigureScope(static (scope, t) => scope.SetTransactionIfNull(t), transaction);
}

public void BindException(Exception exception, ISpan span)
{
// Don't bind on sampled out spans
Expand Down
58 changes: 43 additions & 15 deletions src/Sentry/Scope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,21 +221,7 @@ public ITransactionTracer? Transaction
_transactionLock.EnterWriteLock();
try
{
_transaction.Value = value;

if (Options.EnableScopeSync)
{
if (_transaction.Value != null)
{
// If there is a transaction set we propagate the trace to the native layer
Options.ScopeObserver?.SetTrace(_transaction.Value.TraceId, _transaction.Value.SpanId);
}
else
{
// If the transaction is being removed from the scope, reset and sync the trace as well
Options.ScopeObserver?.SetTrace(PropagationContext.TraceId, PropagationContext.SpanId);
}
}
SetTransactionValue(value);
}
finally
{
Expand All @@ -244,6 +230,48 @@ public ITransactionTracer? Transaction
}
}

// Must be called while holding the write lock on _transactionLock.
private void SetTransactionValue(ITransactionTracer? value)
{
_transaction.Value = value;

if (Options.EnableScopeSync)
{
if (_transaction.Value != null)
{
// If there is a transaction set we propagate the trace to the native layer
Options.ScopeObserver?.SetTrace(_transaction.Value.TraceId, _transaction.Value.SpanId);
}
else
{
// If the transaction is being removed from the scope, reset and sync the trace as well
Options.ScopeObserver?.SetTrace(PropagationContext.TraceId, PropagationContext.SpanId);
}
}
}

/// <summary>
/// Atomically sets <paramref name="transaction"/> on the scope only if there isn't already an (unfinished)
/// transaction set. Used to implement <see cref="SentryOptions.AutoSetScopeTransactions"/> without overwriting a
/// transaction that was set manually or by another integration.
/// </summary>
internal void SetTransactionIfNull(ITransactionTracer transaction)
{
_transactionLock.EnterWriteLock();
try
{
// Treat a finished transaction as "not set" - this mirrors the getter, which returns null in that case.
if (_transaction.Value is null or { IsFinished: true })
{
SetTransactionValue(transaction);
}
}
finally
{
_transactionLock.ExitWriteLock();
}
}

internal SentryPropagationContext PropagationContext { get; private set; }

internal SessionUpdate? SessionUpdate { get; set; }
Expand Down
18 changes: 18 additions & 0 deletions src/Sentry/SentryOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,24 @@ public bool IsGlobalModeEnabled
/// </remarks>
public bool EnableBackpressureHandling { get; set; } = true;

/// <summary>
/// When enabled, starting a transaction (for example via <see cref="SentrySdk.StartTransaction(string, string)"/>)
/// automatically stores it on the current scope, provided the scope does not already have a transaction set. This
/// means APIs such as <see cref="SentrySdk.GetSpan"/> work without having to manually call
/// <c>SentrySdk.ConfigureScope(scope =&gt; scope.Transaction = transaction)</c>.
/// </summary>
/// <remarks>
/// Defaults to <c>false</c>. An automatically stored transaction is only ever cleared from the scope by the SDK if
/// the scope still references that exact transaction when it finishes, so a transaction set manually (or by another
/// integration) is never overwritten or cleared.
/// <para>
/// Note that <see cref="Scope.Transaction"/> is backed by an <see cref="System.Threading.AsyncLocal{T}"/>. In global
/// scope mode (see <see cref="IsGlobalModeEnabled"/>) the same caution that applies to setting
/// <see cref="Scope.Transaction"/> manually applies to enabling this option.
/// </para>
/// </remarks>
public bool AutoSetScopeTransactions { get; set; } = false;

/// <summary>
/// This holds a reference to the current transport, when one is active.
/// If set manually before initialization, the provided transport will be used instead of the default transport.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,7 @@ namespace Sentry
public bool AttachStacktrace { get; set; }
public bool AutoSessionTracking { get; set; }
public System.TimeSpan AutoSessionTrackingInterval { get; set; }
public bool AutoSetScopeTransactions { get; set; }
public Sentry.Extensibility.IBackgroundWorker? BackgroundWorker { get; set; }
public string? CacheDirectoryPath { get; set; }
public bool CaptureFailedRequests { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,7 @@ namespace Sentry
public bool AttachStacktrace { get; set; }
public bool AutoSessionTracking { get; set; }
public System.TimeSpan AutoSessionTrackingInterval { get; set; }
public bool AutoSetScopeTransactions { get; set; }
public Sentry.Extensibility.IBackgroundWorker? BackgroundWorker { get; set; }
public string? CacheDirectoryPath { get; set; }
public bool CaptureFailedRequests { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,7 @@ namespace Sentry
public bool AttachStacktrace { get; set; }
public bool AutoSessionTracking { get; set; }
public System.TimeSpan AutoSessionTrackingInterval { get; set; }
public bool AutoSetScopeTransactions { get; set; }
public Sentry.Extensibility.IBackgroundWorker? BackgroundWorker { get; set; }
public string? CacheDirectoryPath { get; set; }
public bool CaptureFailedRequests { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,7 @@ namespace Sentry
public bool AttachStacktrace { get; set; }
public bool AutoSessionTracking { get; set; }
public System.TimeSpan AutoSessionTrackingInterval { get; set; }
public bool AutoSetScopeTransactions { get; set; }
public Sentry.Extensibility.IBackgroundWorker? BackgroundWorker { get; set; }
public string? CacheDirectoryPath { get; set; }
public bool CaptureFailedRequests { get; set; }
Expand Down
143 changes: 143 additions & 0 deletions test/Sentry.Tests/HubTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,149 @@ public void StartTransaction_NameOpDescription_Works()
transaction.Description.Should().Be("description");
}

[Fact]
public void StartTransaction_AutoSetScopeTransactionsDisabled_DoesNotSetOnScope()
{
// Arrange
_fixture.Options.TracesSampleRate = 1.0;
_fixture.Options.AutoSetScopeTransactions = false; // the default
var hub = _fixture.GetSut();

// Act
var transaction = hub.StartTransaction("name", "operation");

// Assert
var scope = hub.ScopeManager.GetCurrent().Key;
scope.Transaction.Should().BeNull();
hub.GetSpan().Should().BeNull();
}

[Fact]
public void StartTransaction_AutoSetScopeTransactionsEnabled_SetsOnScopeWhenEmpty()
{
// Arrange
_fixture.Options.TracesSampleRate = 1.0;
_fixture.Options.AutoSetScopeTransactions = true;
var hub = _fixture.GetSut();

// Act
var transaction = hub.StartTransaction("name", "operation");

// Assert
var scope = hub.ScopeManager.GetCurrent().Key;
scope.Transaction.Should().BeSameAs(transaction);
hub.GetSpan().Should().BeSameAs(transaction);
}

[Fact]
public void StartTransaction_AutoSetScopeTransactionsEnabled_DoesNotOverwriteExistingTransaction()
{
// Arrange
_fixture.Options.TracesSampleRate = 1.0;
_fixture.Options.AutoSetScopeTransactions = true;
var hub = _fixture.GetSut();
var existing = hub.StartTransaction("existing", "operation");
var scope = hub.ScopeManager.GetCurrent().Key;
scope.Transaction = existing;

// Act
var newTransaction = hub.StartTransaction("new", "operation");

// Assert - the manually set transaction must not be overwritten
scope.Transaction.Should().BeSameAs(existing);
newTransaction.Should().NotBeSameAs(existing);
}

[Fact]
public void StartTransaction_AutoSetScopeTransactionsEnabled_ClearedFromScopeOnFinish()
{
// Arrange
_fixture.Options.TracesSampleRate = 1.0;
_fixture.Options.AutoSetScopeTransactions = true;
var hub = _fixture.GetSut();
var transaction = hub.StartTransaction("name", "operation");
var scope = hub.ScopeManager.GetCurrent().Key;
scope.Transaction.Should().BeSameAs(transaction);

// Act
transaction.Finish();

// Assert
scope.Transaction.Should().BeNull();
}

[Fact]
public void StartTransaction_AutoSetScopeTransactionsEnabled_FinishDoesNotClearOverriddenTransaction()
{
// Arrange
_fixture.Options.TracesSampleRate = 1.0;
_fixture.Options.AutoSetScopeTransactions = true;
var hub = _fixture.GetSut();
var autoSet = hub.StartTransaction("auto", "operation");
var scope = hub.ScopeManager.GetCurrent().Key;
scope.Transaction.Should().BeSameAs(autoSet);

// The user (or another integration) replaces the scope transaction after ours was auto-set.
var replacement = hub.StartTransaction("replacement", "operation");
scope.Transaction = replacement;

// Act - finishing the originally auto-set transaction must not clear the replacement
autoSet.Finish();

// Assert
scope.Transaction.Should().BeSameAs(replacement);
}

[Fact]
public void StartTransaction_AutoSetScopeTransactionsEnabled_SetsUnsampledTransactionOnScope()
{
// Arrange - sampled out so StartTransaction returns an UnsampledTransaction
_fixture.Options.TracesSampleRate = 0.0;
_fixture.Options.AutoSetScopeTransactions = true;
var hub = _fixture.GetSut();

// Act
var transaction = hub.StartTransaction("name", "operation");

// Assert
transaction.IsSampled.Should().BeFalse();
var scope = hub.ScopeManager.GetCurrent().Key;
scope.Transaction.Should().BeSameAs(transaction);
}

[Fact]
public void StartSpan_AutoSetScopeTransactionOverrideFalse_DoesNotSetOnScope_EvenWhenOptionEnabled()
{
// Arrange
_fixture.Options.TracesSampleRate = 1.0;
_fixture.Options.AutoSetScopeTransactions = true;
var hub = _fixture.GetSut();

// Act - the override wins over the global option (this is what the MAUI binder relies on)
var span = hub.StartSpan("operation", "description", autoSetScopeTransaction: false);

// Assert
span.Should().BeAssignableTo<ITransactionTracer>();
var scope = hub.ScopeManager.GetCurrent().Key;
scope.Transaction.Should().BeNull();
}

[Fact]
public void StartSpan_AutoSetScopeTransactionOverrideTrue_SetsOnScope_EvenWhenOptionDisabled()
{
// Arrange
_fixture.Options.TracesSampleRate = 1.0;
_fixture.Options.AutoSetScopeTransactions = false;
var hub = _fixture.GetSut();

// Act - the override wins over the (disabled) global option
var span = hub.StartSpan("operation", "description", autoSetScopeTransaction: true);

// Assert
var scope = hub.ScopeManager.GetCurrent().Key;
scope.Transaction.Should().BeSameAs(span);
}

[Fact]
public void StartTransaction_FromTraceHeader_CopiesContext()
{
Expand Down
Loading