Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
3ef1422
Associate replays with errors and traces on Android
jamescrosswell Apr 22, 2025
9872376
Format code
getsentry-bot Apr 23, 2025
b8d9452
Set replay_id on DSC rather than directly on the event Context
jamescrosswell Apr 23, 2025
ae6157e
Update CHANGELOG.md
jamescrosswell Apr 23, 2025
6f848a3
Verify NetFX
getsentry-bot Apr 27, 2025
05c674c
Merge branch 'main' into replay-link-events
jamescrosswell Apr 28, 2025
cf534b9
Update CHANGELOG.md
jamescrosswell Apr 28, 2025
ff8019b
DSC tests
jamescrosswell Apr 28, 2025
0989eca
Hub Tests
jamescrosswell Apr 28, 2025
b43f1bf
Update AndroidEventProcessor.cs
jamescrosswell Apr 28, 2025
f6a8242
Protocol tests
jamescrosswell Apr 28, 2025
6386234
Fix verify tests
jamescrosswell Apr 28, 2025
41ba57d
Update DynamicSamplingContextTests.cs
jamescrosswell Apr 28, 2025
75997c6
Windows verify tests
getsentry-bot Apr 28, 2025
32ba50b
Format code
getsentry-bot Apr 28, 2025
efb200d
Merge branch 'main' into replay-link-events
jamescrosswell Apr 28, 2025
6d770cb
Ensure any replay_id in the PropagationContext is used when creating …
jamescrosswell Apr 29, 2025
37b40c5
Prefer our session replay id over the propagated ones
jamescrosswell May 1, 2025
356d39d
Converted static ReplayHelper to a Singleton (for cleaner testing)
jamescrosswell May 2, 2025
66e3f80
Add replayId scenarios to SentryPropagationContextTests
jamescrosswell May 2, 2025
edc0b9c
Format code
getsentry-bot May 2, 2025
81c4f50
Merge branch 'main' into replay-link-events
getsentry-bot May 2, 2025
b50ae47
Merge branch 'main' into replay-link-events
getsentry-bot May 2, 2025
7b95524
Windows verify tests
getsentry-bot May 2, 2025
330bd66
Merge branch 'replay-link-events' of https://github.com/getsentry/sen…
getsentry-bot May 2, 2025
39e56fd
Update ReplaySession.cs
jamescrosswell May 2, 2025
4f2f39c
Ensure trace or propagation context always gets applied to event
jamescrosswell May 4, 2025
4c2fc76
Merge branch 'main' into replay-link-events
jamescrosswell May 4, 2025
8ef8d66
Update CHANGELOG.md
jamescrosswell May 4, 2025
eee7a20
Merge branch 'main' into replay-link-events
jamescrosswell May 8, 2025
4ce0049
Removed ReplaySession.DisabledInstance
jamescrosswell May 8, 2025
c3ab0b4
Review feedback
jamescrosswell May 9, 2025
cf96b60
Update CHANGELOG.md
jamescrosswell May 9, 2025
b989d5c
Merge branch 'main' into replay-link-events
jamescrosswell May 10, 2025
54e5536
Update src/Sentry/DynamicSamplingContext.cs
jamescrosswell May 12, 2025
0b21161
Merge branch 'main' into replay-link-events
jamescrosswell May 12, 2025
369b529
Merge branch 'main' into replay-link-events
vaind May 13, 2025
438c4e6
chore: fixup changelog merge changes
vaind May 13, 2025
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
15 changes: 8 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@

## Unreleased

### Features

- New source generator allows Sentry to see true build variables like PublishAot and PublishTrimmed to properly adapt checks in the Sentry SDK ([#4101](https://github.com/getsentry/sentry-dotnet/pull/4101))
- Auto breadcrumbs now include all .NET MAUI gesture recognizer events ([#4124](https://github.com/getsentry/sentry-dotnet/pull/4124))
- Associate replays with errors and traces on Android ([#4133](https://github.com/getsentry/sentry-dotnet/pull/4133))

### Fixes

- Redact Authorization headers before sending events to Sentry ([#4164](https://github.com/getsentry/sentry-dotnet/pull/4164))
- Remove Strong Naming from Sentry.Hangfire ([#4099](https://github.com/getsentry/sentry-dotnet/pull/4099))
- Increase `RequestSize.Small` threshold from 1 kB to 4 kB to match other SDKs ([#4177](https://github.com/getsentry/sentry-dotnet/pull/4177))

### Features

- New source generator allows Sentry to see true build variables like PublishAot and PublishTrimmed to properly adapt checks in the Sentry SDK ([#4101](https://github.com/getsentry/sentry-dotnet/pull/4101))
- Auto breadcrumbs now include all .NET MAUI gesture recognizer events ([#4124](https://github.com/getsentry/sentry-dotnet/pull/4124))

### Dependencies

- Bump CLI from v2.43.1 to v2.45.0 ([#4169](https://github.com/getsentry/sentry-dotnet/pull/4169), [#4179](https://github.com/getsentry/sentry-dotnet/pull/4179))
Expand All @@ -23,7 +24,7 @@

### Features

- When setting a transaction on the scope, the SDK will attempt to sync the transaction's trace context with the SDK on the native layer. Finishing a transaction will now also start a new trace ([#4153](https://github.com/getsentry/sentry-dotnet/pull/4153))
- When setting a transaction on the scope, the SDK will attempt to sync the transaction's trace context with the SDK on the native layer. Finishing a transaction will now also start a new trace ([#4153](https://github.com/getsentry/sentry-dotnet/pull/4153))
- Added `CaptureFeedback` overload with `configureScope` parameter ([#4073](https://github.com/getsentry/sentry-dotnet/pull/4073))
- Custom SessionReplay masks in MAUI Android apps ([#4121](https://github.com/getsentry/sentry-dotnet/pull/4121))

Expand All @@ -45,7 +46,7 @@
### Features

- Option to disable the SentryNative integration ([#4107](https://github.com/getsentry/sentry-dotnet/pull/4107), [#4134](https://github.com/getsentry/sentry-dotnet/pull/4134))
- To disable it, add this msbuild property: `<SentryNative>false</SentryNative>`
- To disable it, add this msbuild property: `<SentryNative>false</SentryNative>`
- Reintroduced experimental support for Session Replay on Android ([#4097](https://github.com/getsentry/sentry-dotnet/pull/4097))
- If an incoming HTTP request has the `traceparent` header, it is now parsed and interpreted like the `sentry-trace` header. Outgoing requests now contain the `traceparent` header to facilitate integration with servesr that only support the [W3C Trace Context](https://www.w3.org/TR/trace-context/). ([#4084](https://github.com/getsentry/sentry-dotnet/pull/4084))

Expand Down
3 changes: 2 additions & 1 deletion src/Sentry.AspNet/HttpContextExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Sentry.Extensibility;
using Sentry.Internal;
using Sentry.Protocol;

namespace Sentry.AspNet;
Expand Down Expand Up @@ -125,7 +126,7 @@ public static ITransactionTracer StartSentryTransaction(this HttpContext httpCon
["__HttpContext"] = httpContext,
};

// Set the Dynamic Sampling Context from the baggage header, if it exists.
// Set the Dynamic Sampling Context from the baggage header, if it exists
var dynamicSamplingContext = baggageHeader?.CreateDynamicSamplingContext();

if (traceHeader is not null && baggageHeader is null)
Expand Down
2 changes: 1 addition & 1 deletion src/Sentry.AspNetCore/SentryTracingMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.Extensions.Options;
using Sentry.AspNetCore.Extensions;
using Sentry.Extensibility;
using Sentry.Internal;
using Sentry.Internal.OpenTelemetry;

namespace Sentry.AspNetCore;
Expand Down Expand Up @@ -64,7 +65,6 @@ public SentryTracingMiddleware(
? traceHeaderObject as SentryTraceHeader : null;
var baggageHeader = context.Items.TryGetValue(SentryMiddleware.BaggageHeaderItemKey, out var baggageHeaderObject)
? baggageHeaderObject as BaggageHeader : null;

var dynamicSamplingContext = baggageHeader?.CreateDynamicSamplingContext();

if (traceHeader is not null && baggageHeader is null)
Expand Down
8 changes: 5 additions & 3 deletions src/Sentry.OpenTelemetry/SentrySpanProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public class SentrySpanProcessor : BaseProcessor<Activity>
{
private readonly IHub _hub;
internal readonly IEnumerable<IOpenTelemetryEnricher> _enrichers;
private readonly IReplaySession _replaySession;
internal const string OpenTelemetryOrigin = "auto.otel";

// ReSharper disable once MemberCanBePrivate.Global - Used by tests
Expand All @@ -38,7 +39,7 @@ public SentrySpanProcessor(IHub hub) : this(hub, null)
{
}

internal SentrySpanProcessor(IHub hub, IEnumerable<IOpenTelemetryEnricher>? enrichers)
internal SentrySpanProcessor(IHub hub, IEnumerable<IOpenTelemetryEnricher>? enrichers, IReplaySession? replaySession = null)
{
_hub = hub;
_realHub = new Lazy<Hub?>(() =>
Expand All @@ -57,7 +58,8 @@ internal SentrySpanProcessor(IHub hub, IEnumerable<IOpenTelemetryEnricher>? enri
"You should use the TracerProviderBuilderExtensions to configure Sentry with OpenTelemetry");
}

_enrichers = enrichers ?? Enumerable.Empty<IOpenTelemetryEnricher>();
_enrichers = enrichers ?? [];
_replaySession = replaySession ?? ReplaySession.Instance;
_options = hub.GetSentryOptions();

if (_options is null)
Expand Down Expand Up @@ -158,7 +160,7 @@ private void CreateRootSpan(Activity data)
};

var baggageHeader = data.Baggage.AsBaggageHeader();
var dynamicSamplingContext = baggageHeader.CreateDynamicSamplingContext();
var dynamicSamplingContext = baggageHeader.CreateDynamicSamplingContext(_replaySession);
var transaction = (TransactionTracer)_hub.StartTransaction(
transactionContext, new Dictionary<string, object?>(), dynamicSamplingContext
);
Expand Down
62 changes: 45 additions & 17 deletions src/Sentry/DynamicSamplingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ internal class DynamicSamplingContext
/// </summary>
public static readonly DynamicSamplingContext Empty = new(new Dictionary<string, string>().AsReadOnly());

private DynamicSamplingContext(
SentryId traceId,
private DynamicSamplingContext(SentryId traceId,
string publicKey,
bool? sampled,
double? sampleRate = null,
double? sampleRand = null,
string? release = null,
string? environment = null,
string? transactionName = null)
string? transactionName = null,
IReplaySession? replaySession = null)
{
// Validate and set required values
if (traceId == SentryId.Empty)
Expand All @@ -51,7 +51,7 @@ private DynamicSamplingContext(
throw new ArgumentOutOfRangeException(nameof(sampleRand), "Arg invalid if < 0.0 or >= 1.0");
}

var items = new Dictionary<string, string>(capacity: 8)
var items = new Dictionary<string, string>(capacity: 9)
{
["trace_id"] = traceId.ToString(),
["public_key"] = publicKey,
Expand Down Expand Up @@ -88,12 +88,29 @@ private DynamicSamplingContext(
items.Add("transaction", transactionName);
}

if (replaySession?.ActiveReplayId is { } replayId && replayId != SentryId.Empty)
{
items.Add("replay_id", replayId.ToString());
}

Items = items;
}

public BaggageHeader ToBaggageHeader() => BaggageHeader.Create(Items, useSentryPrefix: true);

public static DynamicSamplingContext? CreateFromBaggageHeader(BaggageHeader baggage)
public DynamicSamplingContext WithReplayId(IReplaySession? replaySession)
{
if (replaySession?.ActiveReplayId is not { } replayId || replayId == SentryId.Empty)
{
return this;
}

var items = Items.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
items["replay_id"] = replayId.ToString();
return new DynamicSamplingContext(items);
}

public static DynamicSamplingContext? CreateFromBaggageHeader(BaggageHeader baggage, IReplaySession? replaySession)
{
var items = baggage.GetSentryMembers();

Expand Down Expand Up @@ -144,10 +161,19 @@ private DynamicSamplingContext(
}
items.Add("sample_rand", rand.ToString("N4", CultureInfo.InvariantCulture));
}

if (replaySession?.ActiveReplayId is { } replayId)
{
// Any upstream replay_id will be propagated only if the current process hasn't started it's own replay session.
// Otherwise we have to overwrite this as it's the only way to communicate the replayId to Sentry Relay.
// In Mobile apps this should never be a problem.
items["replay_id"] = replayId.ToString();
}

return new DynamicSamplingContext(items);
}

public static DynamicSamplingContext CreateFromTransaction(TransactionTracer transaction, SentryOptions options)
public static DynamicSamplingContext CreateFromTransaction(TransactionTracer transaction, SentryOptions options, IReplaySession? replaySession)
{
// These should already be set on the transaction.
var publicKey = options.ParsedDsn.PublicKey;
Expand All @@ -161,18 +187,18 @@ public static DynamicSamplingContext CreateFromTransaction(TransactionTracer tra
var release = options.SettingLocator.GetRelease();
var environment = options.SettingLocator.GetEnvironment();

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

public static DynamicSamplingContext CreateFromPropagationContext(SentryPropagationContext propagationContext, SentryOptions options)
public static DynamicSamplingContext CreateFromPropagationContext(SentryPropagationContext propagationContext, SentryOptions options, IReplaySession? replaySession)
{
var traceId = propagationContext.TraceId;
var publicKey = options.ParsedDsn.PublicKey;
Expand All @@ -184,18 +210,20 @@ public static DynamicSamplingContext CreateFromPropagationContext(SentryPropagat
publicKey,
null,
release: release,
environment: environment);
environment: environment,
replaySession: replaySession
);
}
}

internal static class DynamicSamplingContextExtensions
{
public static DynamicSamplingContext? CreateDynamicSamplingContext(this BaggageHeader baggage)
=> DynamicSamplingContext.CreateFromBaggageHeader(baggage);
public static DynamicSamplingContext? CreateDynamicSamplingContext(this BaggageHeader baggage, IReplaySession? replaySession = null)
=> DynamicSamplingContext.CreateFromBaggageHeader(baggage, replaySession);

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

public static DynamicSamplingContext CreateDynamicSamplingContext(this SentryPropagationContext propagationContext, SentryOptions options)
=> DynamicSamplingContext.CreateFromPropagationContext(propagationContext, options);
public static DynamicSamplingContext CreateDynamicSamplingContext(this SentryPropagationContext propagationContext, SentryOptions options, IReplaySession? replaySession)
=> DynamicSamplingContext.CreateFromPropagationContext(propagationContext, options, replaySession);
Comment on lines +221 to +228
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of adding replay to all the factories, how about we use just the WithReplayId()?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather avoid WithReplayId where possible as it has to recreate the DynamicSamplingContext in order to add the replay id... that would result in us creating the DynamicSamplingContext twice for each of the factories if there was an active replay session 😞

The other option would be to change DynamicSamplingContext.Items from a read only dictionary to a mutable dictionary. Most of the stuff in there really shouldn't be messed with once it's created (we're supposed to be propagating stuff verbatim), so until now it's been nice to have this be immutable.

All of this stuff is internal, so we can easily refactor in a separate PR whenever we like, if we want to go with a different coding style.

}
23 changes: 11 additions & 12 deletions src/Sentry/Internal/Hub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ internal class Hub : IHub, IDisposable
private readonly ISessionManager _sessionManager;
private readonly SentryOptions _options;
private readonly RandomValuesFactory _randomValuesFactory;
private readonly IReplaySession _replaySession;

#if MEMORY_DUMP_SUPPORTED
private readonly MemoryMonitor? _memoryMonitor;
Expand All @@ -39,7 +40,8 @@ internal Hub(
ISessionManager? sessionManager = null,
ISystemClock? clock = null,
IInternalScopeManager? scopeManager = null,
RandomValuesFactory? randomValuesFactory = null)
RandomValuesFactory? randomValuesFactory = null,
IReplaySession? replaySession = null)
{
if (string.IsNullOrWhiteSpace(options.Dsn))
{
Expand All @@ -55,7 +57,7 @@ internal Hub(
_sessionManager = sessionManager ?? new GlobalSessionManager(options);
_clock = clock ?? SystemClock.Clock;
client ??= new SentryClient(options, randomValuesFactory: _randomValuesFactory, sessionManager: _sessionManager);

_replaySession = replaySession ?? ReplaySession.Instance;
ScopeManager = scopeManager ?? new SentryScopeManager(options, client);

if (!options.IsGlobalModeEnabled)
Expand Down Expand Up @@ -178,10 +180,10 @@ _options.TransactionProfilerFactory is { } profilerFactory &&
}
}

// Use the provided DSC, or create one based on this transaction.
// 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 ?? transaction.CreateDynamicSamplingContext(_options);
transaction.DynamicSamplingContext = dynamicSamplingContext?.WithReplayId(_replaySession)
?? transaction.CreateDynamicSamplingContext(_options, _replaySession);

// 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 @@ -224,7 +226,7 @@ public BaggageHeader GetBaggage()
}

var propagationContext = CurrentScope.PropagationContext;
return propagationContext.GetOrCreateDynamicSamplingContext(_options).ToBaggageHeader();
return propagationContext.GetOrCreateDynamicSamplingContext(_options, _replaySession).ToBaggageHeader();
}

public TransactionContext ContinueTrace(
Expand Down Expand Up @@ -254,7 +256,7 @@ public TransactionContext ContinueTrace(
string? name = null,
string? operation = null)
{
var propagationContext = SentryPropagationContext.CreateFromHeaders(_options.DiagnosticLogger, traceHeader, baggageHeader);
var propagationContext = SentryPropagationContext.CreateFromHeaders(_options.DiagnosticLogger, traceHeader, baggageHeader, _replaySession);
ConfigureScope(scope => scope.SetPropagationContext(propagationContext));

return new TransactionContext(
Expand Down Expand Up @@ -382,7 +384,7 @@ private void ApplyTraceContextToEvent(SentryEvent evt, SentryPropagationContext
evt.Contexts.Trace.TraceId = propagationContext.TraceId;
evt.Contexts.Trace.SpanId = propagationContext.SpanId;
evt.Contexts.Trace.ParentSpanId = propagationContext.ParentSpanId;
evt.DynamicSamplingContext = propagationContext.GetOrCreateDynamicSamplingContext(_options);
evt.DynamicSamplingContext = propagationContext.GetOrCreateDynamicSamplingContext(_options, _replaySession);
}

public bool CaptureEnvelope(Envelope envelope) => CurrentClient.CaptureEnvelope(envelope);
Expand Down Expand Up @@ -473,10 +475,7 @@ private SentryId CaptureEvent(SentryEvent evt, SentryHint? hint, Scope scope)
var span = GetLinkedSpan(evt) ?? scope.Span;
if (span is not null)
{
if (span.IsSampled is not false)
{
ApplyTraceContextToEvent(evt, span);
}
ApplyTraceContextToEvent(evt, span);
}
else
{
Expand Down
33 changes: 33 additions & 0 deletions src/Sentry/Internal/ReplaySession.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#if __ANDROID__
using Sentry.Android.Extensions;
#endif

namespace Sentry.Internal;

internal interface IReplaySession
{
public SentryId? ActiveReplayId { get; }
}

internal class ReplaySession : IReplaySession
{
public static readonly IReplaySession Instance = new ReplaySession();

private ReplaySession()
{
}

public SentryId? ActiveReplayId
{
get
{
#if __ANDROID__
// Check to see if a Replay ID is available
var replayId = JavaSdk.ScopesAdapter.Instance?.Options?.ReplayController?.ReplayId?.ToSentryId();
return (replayId is { } id && id != SentryId.Empty) ? id : null;
#else
return null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the idea to add iOS here later? potentially Web for example if we were brave enough?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was planning for at least iOS...

#endif
}
}
}
9 changes: 5 additions & 4 deletions src/Sentry/SentryPropagationContext.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Sentry.Extensibility;
using Sentry.Internal;

namespace Sentry;

Expand All @@ -10,12 +11,12 @@ internal class SentryPropagationContext

internal DynamicSamplingContext? _dynamicSamplingContext;

public DynamicSamplingContext GetOrCreateDynamicSamplingContext(SentryOptions options)
public DynamicSamplingContext GetOrCreateDynamicSamplingContext(SentryOptions options, IReplaySession replaySession)
{
if (_dynamicSamplingContext is null)
{
options.LogDebug("Creating the Dynamic Sampling Context from the Propagation Context");
_dynamicSamplingContext = this.CreateDynamicSamplingContext(options);
_dynamicSamplingContext = this.CreateDynamicSamplingContext(options, replaySession);
}

return _dynamicSamplingContext;
Expand Down Expand Up @@ -47,7 +48,7 @@ public SentryPropagationContext(SentryPropagationContext? other)
_dynamicSamplingContext = other?._dynamicSamplingContext;
}

public static SentryPropagationContext CreateFromHeaders(IDiagnosticLogger? logger, SentryTraceHeader? traceHeader, BaggageHeader? baggageHeader)
public static SentryPropagationContext CreateFromHeaders(IDiagnosticLogger? logger, SentryTraceHeader? traceHeader, BaggageHeader? baggageHeader, IReplaySession replaySession)
{
logger?.LogDebug("Creating a propagation context from headers.");

Expand All @@ -57,7 +58,7 @@ public static SentryPropagationContext CreateFromHeaders(IDiagnosticLogger? logg
return new SentryPropagationContext();
}

var dynamicSamplingContext = baggageHeader?.CreateDynamicSamplingContext();
var dynamicSamplingContext = baggageHeader?.CreateDynamicSamplingContext(replaySession);
return new SentryPropagationContext(traceHeader.TraceId, traceHeader.SpanId, dynamicSamplingContext);
}
}
Loading
Loading