Skip to content

fix: Propagate the incoming Dynamic Sampling Context when starting a transaction from ContinueTrace - #5569

Closed
cuva wants to merge 2 commits into
getsentry:mainfrom
raycast:fix/continue-trace-propagates-dsc
Closed

cuva wants to merge 2 commits into
getsentry:mainfrom
raycast:fix/continue-trace-propagates-dsc

Conversation

@cuva

@cuva cuva commented Sep 11, 2026

Copy link
Copy Markdown

Description

SentrySdk.ContinueTrace(traceHeader, baggageHeader) followed by SentrySdk.StartTransaction(context) is the documented way to continue a distributed trace from a non-HTTP boundary (#2646). The baggage is parsed into a DynamicSamplingContext and stored on the scope's propagation context, but Hub.StartTransaction(ITransactionContext, customSamplingContext) always passes null as the DSC to the internal overload, so the transaction creates a fresh DSC from itself: a locally generated sample_rand, the local public_key, the local sample_rate, and so on. The incoming DSC is only honoured by Sentry.AspNetCore, which reaches the internal StartTransaction(context, customSamplingContext, dynamicSamplingContext) overload through InternalsVisibleTo.

This change lets ContinueTrace attach the DSC parsed from the baggage to the TransactionContext it returns (as an internal property), and makes the public StartTransaction(context, customSamplingContext) overload pass that DSC to the internal overload. A transaction started from a continued trace now propagates the incoming DSC unchanged, and sample_rand follows from it, exactly like the ASP.NET Core middleware already does. No public API is added or changed.

When ContinueTrace receives a sentry-trace header without a baggage header, the request comes from an SDK without dynamic sampling support and the DSC is frozen as empty, again matching the ASP.NET Core middleware and the spec. When the baggage does not yield a valid DSC, or when there is no trace header to continue, the behaviour is unchanged and the transaction creates its own DSC.

Motivation and Context

The dynamic sampling context spec says a received DSC MUST be treated as frozen and propagated unchanged, and the propagated random value section requires sample_rand to stay the same across a trace so that Relay reaches one consistent keep-or-drop decision. Today a .NET transaction that continues a trace from headers reports its own sample_rand and public_key, so Relay can sample it independently of the rest of the trace.

Our use case is a desktop app where a web view (JavaScript SDK) starts the trace and the .NET host continues it for the requests it handles, the same situation as #4021. The only workaround is reflection into the internal overload, since DynamicSamplingContext, BaggageHeader.TryParse and the DSC-taking StartTransaction overload are all internal.

Related: #2646, #4021, #3838.

How did you test it?

  • Added six tests to HubTests: sampled and sampled-out continued traces propagate the incoming DSC and sample_rand, the string-header overload does the same, a trace header without baggage freezes an empty DSC, and invalid baggage or no trace header still create a DSC from the transaction. The three baggage tests and the empty-freeze test fail on main and pass with this change.
  • dotnet test test/Sentry.Tests -f net10.0 passes, including the public API approval tests (no public API change).

Changelog Entry

  • fix: Propagate the incoming Dynamic Sampling Context when starting a transaction from a TransactionContext returned by ContinueTrace, so sample_rand and the other frozen DSC items stay consistent across the trace

…transaction from ContinueTrace

ContinueTrace parses the baggage header into a DynamicSamplingContext and stores it
on the scope's propagation context, but the public StartTransaction overload always
passed null as the DSC to the internal overload. A transaction started from the
returned TransactionContext therefore created a fresh DSC from itself, with a locally
generated sample_rand and the local public_key, so Relay could reach a different
keep-or-drop decision for it than for the rest of the trace. Only Sentry.AspNetCore
honoured the incoming DSC, through the internal overload.

Attach the DSC parsed from the baggage to the TransactionContext returned by
ContinueTrace (internal property) and have the public StartTransaction overload pass
it on, so the incoming DSC is propagated unchanged as the spec requires. Without a
baggage header, or when the baggage yields no valid DSC, the behaviour is unchanged.
@github-actions github-actions Bot added the risk: medium PR risk score: medium label Sep 11, 2026
Comment thread src/Sentry/Internal/Hub.cs Outdated
…ader

A sentry-trace header without a baggage header comes from an SDK without dynamic
sampling support. The ASP.NET Core middleware freezes the DSC as empty in that case,
as the spec requires, so ContinueTrace now does the same instead of letting the
transaction create a DSC from itself. Reuse the propagation context's parsed DSC via
Clone() rather than parsing the baggage a second time.
DynamicSamplingContext = (traceHeader, baggageHeader) switch
{
(null, _) => null,
(_, null) => DynamicSamplingContext.Empty(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: An empty DynamicSamplingContext from ContinueTrace is incorrectly mutated in StartTransaction, violating the spec's requirement for it to remain frozen.
Severity: MEDIUM

Suggested Fix

Prevent the mutation of the empty DynamicSamplingContext within the StartTransaction overload. Before calling SetSampleRate() or SetReplayId(), add a check to ensure the context is not the specific 'frozen' empty instance that is created when a trace header is received without baggage.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/Sentry/Internal/Hub.cs#L422

Potential issue: When `ContinueTrace` is called with a `sentry-trace` header but no
`baggage` header, an empty `DynamicSamplingContext` is created. This context is intended
to be treated as frozen, per Sentry specification, to ensure compatibility with older
SDKs. However, the internal `StartTransaction` overload later mutates this context by
calling `SetSampleRate()` if a global `TracesSampleRate` is configured. This
modification violates the requirement that a received Dynamic Sampling Context (DSC) be
propagated unchanged, causing the context to no longer be empty and breaking the
'frozen' state assumption.

Also affects:

  • src/Sentry/Internal/Hub.cs:171~178

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This one is pre-existing behaviour of the internal StartTransaction overload rather than something this PR introduces. SentryTracingMiddleware has passed DynamicSamplingContext.Empty() into the same overload since the freeze was added, and writing sample_rate onto a provided DSC once a sampling decision is made was done deliberately in #4374. StartTransaction_DynamicSamplingContextWithoutSampleRand_SampleRandNotPropagated covers exactly this path with an empty DSC and only requires that sample_rand is not added.

Changing that here would also change the ASP.NET Core behaviour, so I'd rather keep this PR limited to making ContinueTrace + StartTransaction(context) behave like the middleware. Happy to follow up separately if the maintainers want the empty DSC to stay strictly untouched in both paths.

@jamescrosswell

jamescrosswell commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

SentrySdk.ContinueTrace(traceHeader, baggageHeader) followed by SentrySdk.StartTransaction(context) is the documented way to continue a distributed trace from a non-HTTP boundary (#2646).

I'm not sure that's true... that PR was closed - never merged.

To be honest, I'm not sure if we have an explicit way to continue traces from a non-HTTP boundary. The ContinueTrace method stores trace information in the propagation context which, so far, is only really used to apply trace information to events when tracing is disabled - e.g. here:

// If there is no span on the scope (and not just no sampled one), fall back to the propagation context
ApplyTraceContextToEvent(evt, scope.PropagationContext);

Propagating from non-HTTP boundaries should be possible but it requires a bit of investigation/planning. I won't have time for this immediately as we need to get the SDK ready for .NET 11. I also don't have time to review any moderately complex PRs at the moment.

I've created the following issue so that we can priorities and schedule in this work once the work for .NET 11 is complete:

@jamescrosswell

Copy link
Copy Markdown
Collaborator

Closing for now. Will reopen if this PR turns out to be an appropriate solution when we implement this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: medium PR risk score: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants