Skip to content

WIP: Unsampled Transactions to reduce memory pressure #3972

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

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
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
67 changes: 36 additions & 31 deletions src/Sentry/Internal/Hub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,52 +127,55 @@ internal ITransactionTracer StartTransaction(
IReadOnlyDictionary<string, object?> customSamplingContext,
DynamicSamplingContext? dynamicSamplingContext)
{
var transaction = new TransactionTracer(this, context);

// 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 = _randomValuesFactory.NextBool(sampleRate);
transaction.SampleRate = sampleRate;
}
}
double? sampleRate = null;

// Random sampling runs only if the sampling decision hasn't been made already.
if (transaction.IsSampled == null)
{
var sampleRate = _options.TracesSampleRate ?? 0.0;
transaction.IsSampled = _randomValuesFactory.NextBool(sampleRate);
transaction.SampleRate = sampleRate;
}
// 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 (transaction.IsSampled is true &&
_options.TransactionProfilerFactory is { } profilerFactory &&
_randomValuesFactory.NextBool(_options.ProfilesSampleRate ?? 0.0))
if (tracesSampler(samplingContext) is { } samplerSampleRate)
{
// TODO cancellation token based on Hub being closed?
transaction.TransactionProfiler = profilerFactory.Start(transaction, CancellationToken.None);
sampleRate = samplerSampleRate;
}
}

// If the sampling decision isn't made by a trace sampler then fallback to Random sampling
sampleRate ??= _options.TracesSampleRate ?? 0.0;

var isSampled = _randomValuesFactory.NextBool(sampleRate.Value);
if (!isSampled)
{
// var unsampledTransaction = new UnsampledTransaction(this, context);
// return unsampledTransaction;
return new UnsampledTransaction(this, context);
}

var transaction = new TransactionTracer(this, context)
{
IsSampled = true,
SampleRate = sampleRate
};
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);
}

// Use the provided DSC, or create one based on this transaction.
// DSC creation must be done AFTER the sampling decision has been made.
transaction.DynamicSamplingContext =
Expand Down Expand Up @@ -213,6 +216,8 @@ public SentryTraceHeader GetTraceHeader()
public BaggageHeader GetBaggage()
{
var span = GetSpan();
// TODO: Things like the SampleRand won't get propagated unless we get them from a DSC
// ... so we'd need to get these from an UnsampledTransaction as well as a TransactionTracer
if (span?.GetTransaction() is TransactionTracer { DynamicSamplingContext: { IsEmpty: false } dsc })
{
return dsc.ToBaggageHeader();
Expand Down
20 changes: 10 additions & 10 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 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
83 changes: 83 additions & 0 deletions src/Sentry/Internal/UnsampledTransaction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using Sentry.Extensibility;

namespace Sentry.Internal;

/// <summary>
/// We know already, when starting a transaction, whether it's going to be sampled or not. When it's not sampled, we can
/// avoid lots of unecessary processing. The only thing we need to track is the number of spans that would have been
/// created (the client reports detailing discarded events includes this detail).
/// </summary>
internal sealed class UnsampledTransaction : NoOpTransaction
{
// Although it's a little bit wasteful to create separate individual class instances here when all we're going to
// report to sentry is the span count (in the client report), SDK users may refer to things like
// `ITransaction.Spans.Count`, so we create an actual collection
private readonly ConcurrentBag<ISpan> _spans = [];
private readonly IHub _hub;
private readonly ITransactionContext _context;
private readonly SentryOptions? _options;

public UnsampledTransaction(IHub hub, ITransactionContext context)
{
_hub = hub;
_options = _hub.GetSentryOptions();
_options?.LogDebug("Starting unsampled transaction");
_context = context;
}

internal DynamicSamplingContext? DynamicSamplingContext { get; set; }

public override IReadOnlyCollection<ISpan> Spans => _spans;

public override SpanId SpanId => _context.SpanId;
public override SentryId TraceId => _context.TraceId;
public override bool? IsSampled => false;

public override string Name
{
get => _context.Name;
set { }
}

public override string Operation
{
get => _context.Operation;
set { }
}

public override void Finish()
{
_options?.LogDebug("Finishing unsampled transaction");

// Clear the transaction from the scope
_hub.ConfigureScope(scope => scope.ResetTransaction(this));

// Record the discarded events
var spanCount = Spans.Count + 1; // 1 for each span + 1 for the transaction itself
_options?.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.SampleRate, DataCategory.Transaction);
_options?.ClientReportRecorder.RecordDiscardedEvent(DiscardReason.SampleRate, DataCategory.Span, spanCount);

_options?.LogDebug("Finished unsampled transaction");
}

public override void Finish(SpanStatus status) => Finish();

public override void Finish(Exception exception, SpanStatus status) => Finish();

public override void Finish(Exception exception) => Finish();

/// <inheritdoc />
public override SentryTraceHeader GetTraceHeader() => new(TraceId, SpanId, IsSampled);

public override ISpan StartChild(string operation)
{
var span = new UnsampledSpan(this);
_spans.Add(span);
return span;
}

private class UnsampledSpan(UnsampledTransaction transaction) : NoOpSpan
{
public override ISpan StartChild(string operation) => transaction.StartChild(operation);
}
}