Skip to content

UnsampledTransactions to reduce memory pressure #4212

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
May 29, 2025
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Features

- Reduced memory pressure when sampling less than 100% of traces/transactions ([#4212](https://github.com/getsentry/sentry-dotnet/pull/4212))

### Fixes

- Support Linux arm64 on Native AOT ([#3700](https://github.com/getsentry/sentry-dotnet/pull/3700))
Expand Down
12 changes: 8 additions & 4 deletions src/Sentry.AspNetCore/SentryTracingMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,11 @@ public async Task InvokeAsync(HttpContext context)
}
finally
{
if (transaction is not null)
if (transaction is UnsampledTransaction)
{
transaction.Finish();
}
else if (transaction is TransactionTracer tracer)
{
// The Transaction name was altered during the pipeline execution,
// That could be done by user interference or by some Event Capture
Expand Down Expand Up @@ -183,15 +187,15 @@ public async Task InvokeAsync(HttpContext context)
if (!string.IsNullOrEmpty(customTransactionName))
{
transaction.Name = $"{method} {customTransactionName}";
((TransactionTracer)transaction).NameSource = TransactionNameSource.Custom;
tracer.NameSource = TransactionNameSource.Custom;
}
else
{
// Finally, fallback to using the URL path.
// e.g. "GET /pets/1"
var path = context.Request.Path;
transaction.Name = $"{method} {path}";
((TransactionTracer)transaction).NameSource = TransactionNameSource.Url;
tracer.NameSource = TransactionNameSource.Url;
}
}

Expand All @@ -200,7 +204,7 @@ public async Task InvokeAsync(HttpContext context)
transaction.Finish(status);
}
// Status code not yet changed to 500 but an exception does exist
// so lets avoid passing the misleading 200 down and close only with
// so let's avoid passing the misleading 200 down and close only with
// the exception instance that will be inferred as errored.
else if (status == SpanStatus.Ok)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ private async Task<TransactionContext> StartOrContinueTraceAsync(FunctionContext
if (requestData is null)
{
// not an HTTP trigger
return SentrySdk.ContinueTrace((SentryTraceHeader?)null, (BaggageHeader?)null, transactionName, Operation);
return _hub.ContinueTrace((SentryTraceHeader?)null, (BaggageHeader?)null, transactionName, Operation);
}

var httpMethod = requestData.Method.ToUpperInvariant();
Expand Down
28 changes: 28 additions & 0 deletions src/Sentry/DynamicSamplingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,31 @@ public static DynamicSamplingContext CreateFromTransaction(TransactionTracer tra
replaySession);
}

public static DynamicSamplingContext CreateFromUnsampledTransaction(UnsampledTransaction transaction, SentryOptions options, IReplaySession? replaySession)
{
// These should already be set on the transaction.
var publicKey = options.ParsedDsn.PublicKey;
var traceId = transaction.TraceId;
var sampled = transaction.IsSampled;
var sampleRate = transaction.SampleRate!.Value;
var sampleRand = transaction.SampleRand;
var transactionName = transaction.NameSource.IsHighQuality() ? transaction.Name : null;

// These two may not have been set yet on the transaction, but we can get them directly.
var release = options.SettingLocator.GetRelease();
var environment = options.SettingLocator.GetEnvironment();

return new DynamicSamplingContext(traceId,
publicKey,
sampled,
sampleRate,
sampleRand,
release,
environment,
transactionName,
replaySession);
}

public static DynamicSamplingContext CreateFromPropagationContext(SentryPropagationContext propagationContext, SentryOptions options, IReplaySession? replaySession)
{
var traceId = propagationContext.TraceId;
Expand All @@ -224,6 +249,9 @@ internal static class DynamicSamplingContextExtensions
public static DynamicSamplingContext CreateDynamicSamplingContext(this TransactionTracer transaction, SentryOptions options, IReplaySession? replaySession)
=> DynamicSamplingContext.CreateFromTransaction(transaction, options, replaySession);

public static DynamicSamplingContext CreateDynamicSamplingContext(this UnsampledTransaction transaction, SentryOptions options, IReplaySession? replaySession)
=> DynamicSamplingContext.CreateFromUnsampledTransaction(transaction, options, replaySession);

public static DynamicSamplingContext CreateDynamicSamplingContext(this SentryPropagationContext propagationContext, SentryOptions options, IReplaySession? replaySession)
=> DynamicSamplingContext.CreateFromPropagationContext(propagationContext, options, replaySession);
}
101 changes: 56 additions & 45 deletions src/Sentry/Internal/Hub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,61 +129,72 @@ internal ITransactionTracer StartTransaction(
IReadOnlyDictionary<string, object?> customSamplingContext,
DynamicSamplingContext? dynamicSamplingContext)
{
var transaction = new TransactionTracer(this, context)
{
SampleRand = dynamicSamplingContext?.Items.TryGetValue("sample_rand", out var sampleRand) ?? false
? double.Parse(sampleRand, NumberStyles.Float, CultureInfo.InvariantCulture)
: SampleRandHelper.GenerateSampleRand(context.TraceId.ToString())
};

// 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.
// Additionally, we will always sample out if tracing is explicitly disabled.
// Do not invoke the TracesSampler, evaluate the TracesSampleRate, and override any sampling decision
// that may have been already set (i.e.: from a sentry-trace header).
if (!IsEnabled)
{
transaction.IsSampled = false;
transaction.SampleRate = 0.0;
return NoOpTransaction.Instance;
}
else
{
// Except when tracing is disabled, TracesSampler runs regardless of whether a decision
// has already been made, as it can be used to override it.
if (_options.TracesSampler is { } tracesSampler)
{
var samplingContext = new TransactionSamplingContext(
context,
customSamplingContext);

if (tracesSampler(samplingContext) is { } sampleRate)
{
transaction.IsSampled = SampleRandHelper.IsSampled(transaction.SampleRand.Value, sampleRate);
transaction.SampleRate = sampleRate;
}
}
bool? isSampled = null;
double? sampleRate = null;
var sampleRand = dynamicSamplingContext?.Items.TryGetValue("sample_rand", out var dscsampleRand) ?? false
? double.Parse(dscsampleRand, NumberStyles.Float, CultureInfo.InvariantCulture)
: SampleRandHelper.GenerateSampleRand(context.TraceId.ToString());

// Random sampling runs only if the sampling decision hasn't been made already.
if (transaction.IsSampled == null)
// TracesSampler runs regardless of whether a decision has already been made, as it can be used to override it.
if (_options.TracesSampler is { } tracesSampler)
{
var samplingContext = new TransactionSamplingContext(
context,
customSamplingContext);

if (tracesSampler(samplingContext) is { } samplerSampleRate)
{
var sampleRate = _options.TracesSampleRate ?? 0.0;
transaction.IsSampled = SampleRandHelper.IsSampled(transaction.SampleRand.Value, sampleRate);
transaction.SampleRate = sampleRate;
// The TracesSampler trumps all other sampling decisions (even the trace header)
sampleRate = samplerSampleRate;
isSampled = SampleRandHelper.IsSampled(sampleRand, sampleRate.Value);
}
}

if (transaction.IsSampled is true &&
_options.TransactionProfilerFactory is { } profilerFactory &&
_randomValuesFactory.NextBool(_options.ProfilesSampleRate ?? 0.0))
// If the sampling decision isn't made by a trace sampler we check the trace header first (from the context) or
// finally fallback to Random sampling if the decision has been made by no other means
sampleRate ??= _options.TracesSampleRate ?? 0.0;
isSampled ??= context.IsSampled ?? SampleRandHelper.IsSampled(sampleRand, sampleRate.Value);

// Make sure there is a replayId (if available) on the provided DSC (if any).
dynamicSamplingContext = dynamicSamplingContext?.WithReplayId(_replaySession);

if (isSampled is false)
{
var unsampledTransaction = new UnsampledTransaction(this, context)
{
// TODO cancellation token based on Hub being closed?
transaction.TransactionProfiler = profilerFactory.Start(transaction, CancellationToken.None);
}
SampleRate = sampleRate,
SampleRand = sampleRand,
DynamicSamplingContext = dynamicSamplingContext // Default to the provided DSC
};
// 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);
return unsampledTransaction;
}

// Use the provided DSC (adding the active replayId if necessary), or create one based on this transaction.
// DSC creation must be done AFTER the sampling decision has been made.
transaction.DynamicSamplingContext = dynamicSamplingContext?.WithReplayId(_replaySession)
?? transaction.CreateDynamicSamplingContext(_options, _replaySession);
var transaction = new TransactionTracer(this, context)
{
SampleRate = sampleRate,
SampleRand = sampleRand,
DynamicSamplingContext = dynamicSamplingContext // Default to the provided DSC
};
// 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).
transaction.DynamicSamplingContext ??= transaction.CreateDynamicSamplingContext(_options, _replaySession);

if (_options.TransactionProfilerFactory is { } profilerFactory &&
_randomValuesFactory.NextBool(_options.ProfilesSampleRate ?? 0.0))
{
// TODO cancellation token based on Hub being closed?
transaction.TransactionProfiler = profilerFactory.Start(transaction, CancellationToken.None);
}

// 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.
Expand Down Expand Up @@ -220,7 +231,7 @@ public SentryTraceHeader GetTraceHeader()
public BaggageHeader GetBaggage()
{
var span = GetSpan();
if (span?.GetTransaction() is TransactionTracer { DynamicSamplingContext: { IsEmpty: false } dsc })
if (span?.GetTransaction().GetDynamicSamplingContext() is { IsEmpty: false } dsc)
{
return dsc.ToBaggageHeader();
}
Expand Down Expand Up @@ -373,9 +384,9 @@ private void ApplyTraceContextToEvent(SentryEvent evt, ISpan span)
evt.Contexts.Trace.TraceId = span.TraceId;
evt.Contexts.Trace.ParentSpanId = span.ParentSpanId;

if (span.GetTransaction() is TransactionTracer transactionTracer)
if (span.GetTransaction().GetDynamicSamplingContext() is { } dsc)
{
evt.DynamicSamplingContext = transactionTracer.DynamicSamplingContext;
evt.DynamicSamplingContext = dsc;
}
}

Expand Down
17 changes: 17 additions & 0 deletions src/Sentry/Internal/ITransactionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Sentry.Internal;

internal static class TransactionExtensions
{
public static DynamicSamplingContext? GetDynamicSamplingContext(this ITransactionTracer transaction)
{
if (transaction is UnsampledTransaction unsampledTransaction)
{
return unsampledTransaction.DynamicSamplingContext;
}
if (transaction is TransactionTracer transactionTracer)
{
return transactionTracer.DynamicSamplingContext;
}
return null;
}
}
24 changes: 12 additions & 12 deletions src/Sentry/Internal/NoOpSpan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,18 @@ protected NoOpSpan()
{
}

public SpanId SpanId => SpanId.Empty;
public virtual SpanId SpanId => SpanId.Empty;
public SpanId? ParentSpanId => SpanId.Empty;
public SentryId TraceId => SentryId.Empty;
public bool? IsSampled => default;
public virtual SentryId TraceId => SentryId.Empty;
public virtual bool? IsSampled => default;
public IReadOnlyDictionary<string, string> Tags => ImmutableDictionary<string, string>.Empty;
public IReadOnlyDictionary<string, object?> Extra => ImmutableDictionary<string, object?>.Empty;
public IReadOnlyDictionary<string, object?> Data => ImmutableDictionary<string, object?>.Empty;
public DateTimeOffset StartTimestamp => default;
public DateTimeOffset? EndTimestamp => default;
public bool IsFinished => default;
public DateTimeOffset? EndTimestamp => null;
public virtual bool IsFinished => false;

public string Operation
public virtual string Operation
{
get => string.Empty;
set { }
Expand All @@ -42,21 +42,21 @@ public SpanStatus? Status
set { }
}

public ISpan StartChild(string operation) => this;
public virtual ISpan StartChild(string operation) => this;

public void Finish()
public virtual void Finish()
{
}

public void Finish(SpanStatus status)
public virtual void Finish(SpanStatus status)
{
}

public void Finish(Exception exception, SpanStatus status)
public virtual void Finish(Exception exception, SpanStatus status)
{
}

public void Finish(Exception exception)
public virtual void Finish(Exception exception)
{
}

Expand All @@ -76,7 +76,7 @@ public void SetData(string key, object? value)
{
}

public SentryTraceHeader GetTraceHeader() => SentryTraceHeader.Empty;
public virtual SentryTraceHeader GetTraceHeader() => SentryTraceHeader.Empty;

public IReadOnlyDictionary<string, Measurement> Measurements => ImmutableDictionary<string, Measurement>.Empty;

Expand Down
6 changes: 3 additions & 3 deletions src/Sentry/Internal/NoOpTransaction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ internal class NoOpTransaction : NoOpSpan, ITransactionTracer
{
public new static ITransactionTracer Instance { get; } = new NoOpTransaction();

private NoOpTransaction()
protected NoOpTransaction()
{
}

public SdkVersion Sdk => SdkVersion.Instance;

public string Name
public virtual string Name
{
get => string.Empty;
set { }
Expand Down Expand Up @@ -87,7 +87,7 @@ public IReadOnlyList<string> Fingerprint
set { }
}

public IReadOnlyCollection<ISpan> Spans => ImmutableList<ISpan>.Empty;
public virtual IReadOnlyCollection<ISpan> Spans => ImmutableList<ISpan>.Empty;

public IReadOnlyCollection<Breadcrumb> Breadcrumbs => ImmutableList<Breadcrumb>.Empty;

Expand Down
Loading
Loading