From c24ec9b4ec2e1ab4549e337fd15499883c6c1097 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Wed, 28 Oct 2026 11:43:10 +0100 Subject: [PATCH] fix: transactions continue the external propagation context's trace Co-Authored-By: Claude Opus 5.5 --- .../OtelPropagationContext.cs | 2 +- src/Sentry/Internal/Hub.cs | 24 ++++ .../Internal/IExternalPropagationContext.cs | 2 +- test/Sentry.Tests/HubTests.cs | 106 ++++++++++++++++++ 4 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/Sentry.OpenTelemetry.Exporter/OtelPropagationContext.cs b/src/Sentry.OpenTelemetry.Exporter/OtelPropagationContext.cs index 49b7ae491d..a18bf38c9f 100644 --- a/src/Sentry.OpenTelemetry.Exporter/OtelPropagationContext.cs +++ b/src/Sentry.OpenTelemetry.Exporter/OtelPropagationContext.cs @@ -47,7 +47,7 @@ public SpanId? ParentSpanId } } - public bool IsSampled => Current?.Recorded ?? false; + public bool? IsSampled => Current?.Recorded; /// /// th is a rejection threshold: T = (1 - sampling_probability) * 2^56, so we invert to get the sample rate. diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index 9d93e7f3ee..939d5fda0b 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -192,6 +192,16 @@ internal ITransactionTracer StartTransaction( return NoOpTransaction.Instance; } + if (context.ParentSpanId is null + && _options.ExternalPropagationContext?.Snapshot() is { TraceId: { } externalTraceId } externalPropagationContext) + { + context = ContinueExternalTrace(context, externalTraceId, externalPropagationContext); + if (externalPropagationContext.IsSampled is not null) + { + dynamicSamplingContext ??= externalPropagationContext.GetDynamicSamplingContext(_options, _replaySession); + } + } + bool? isSampled = null; double? sampleRate = null; DiscardReason? discardReason = null; @@ -295,6 +305,20 @@ internal ITransactionTracer StartTransaction( return transaction; } + // Without a sampling decision to inherit, the transaction is a root in the external trace and samples itself. + private static TransactionContext ContinueExternalTrace(ITransactionContext context, SentryId traceId, + IExternalPropagationContext externalPropagationContext) + { + var isSampled = externalPropagationContext.IsSampled; + var parentSpanId = isSampled is null ? null : externalPropagationContext.SpanId; + return new TransactionContext(context.Name, context.Operation, context.SpanId, parentSpanId, traceId, + context.Description, context.Status, isSampled ?? context.IsSampled, isSampled, context.NameSource) + { + Instrumenter = (context as SpanContext)?.Instrumenter ?? Instrumenter.Sentry, + Origin = context.Origin + }; + } + public void BindException(Exception exception, ISpan span) { // Don't bind on sampled out spans diff --git a/src/Sentry/Internal/IExternalPropagationContext.cs b/src/Sentry/Internal/IExternalPropagationContext.cs index 7c5863414b..a3e2556950 100644 --- a/src/Sentry/Internal/IExternalPropagationContext.cs +++ b/src/Sentry/Internal/IExternalPropagationContext.cs @@ -6,7 +6,7 @@ internal interface IExternalPropagationContext public SentryId? TraceId { get; } public SpanId? SpanId { get; } public SpanId? ParentSpanId { get; } - public bool IsSampled { get; } + public bool? IsSampled { get; } public double? SampleRate { get; } public double? SampleRand { get; } diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index d938658545..311b235d5c 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -1894,6 +1894,112 @@ public void CaptureEvent_ExternalPropagationContext_PrefersExternalContext() evt.DynamicSamplingContext.Should().Be(dynamicSamplingContext); } + [Fact] + public void GetTraceHeader_ExternalPropagationContextWithoutSamplingDecision_OmitsDecision() + { + // Arrange + var externalContext = Substitute.For(); + externalContext.Snapshot().Returns(externalContext); + externalContext.TraceId.Returns(SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8")); + externalContext.SpanId.Returns(SpanId.Parse("2000000000000000")); + externalContext.IsSampled.Returns((bool?)null); + _fixture.Options.ExternalPropagationContext = externalContext; + var hub = _fixture.GetSut(); + + // Act + var header = hub.GetTraceHeader(); + + // Assert + header.IsSampled.Should().BeNull(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void StartTransaction_ExternalPropagationContextWithSamplingDecision_ContinuesExternalSpan(bool isSampled) + { + // Arrange + var traceId = SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8"); + var spanId = SpanId.Parse("2000000000000000"); + var dynamicSamplingContext = BaggageHeader.Create(new List> + { + {"sentry-trace_id", "75302ac48a024bde9a3b3734a82e36c8"}, + {"sentry-public_key", "49d0f7386ad645858ae85020e393bef3"}, + {"sentry-sample_rate", "1.0"}, + {"sentry-sample_rand", "0.5"} + }).CreateDynamicSamplingContext(_fixture.ReplaySession); + var externalContext = Substitute.For(); + externalContext.Snapshot().Returns(externalContext); + externalContext.TraceId.Returns(traceId); + externalContext.SpanId.Returns(spanId); + externalContext.IsSampled.Returns(isSampled); + externalContext.GetDynamicSamplingContext(Arg.Any(), Arg.Any()) + .Returns(dynamicSamplingContext); + _fixture.Options.ExternalPropagationContext = externalContext; + _fixture.Options.TracesSampleRate = isSampled ? 0.0 : 1.0; + var hub = _fixture.GetSut(); + + // Act + var transaction = hub.StartTransaction("name", "operation"); + + // Assert + transaction.TraceId.Should().Be(traceId); + transaction.IsSampled.Should().Be(isSampled); + if (isSampled) + { + transaction.ParentSpanId.Should().Be(spanId); + } + transaction.GetDynamicSamplingContext().Should().Be(dynamicSamplingContext); + } + + [Fact] + public void StartTransaction_ExternalPropagationContextWithoutSamplingDecision_IsRootInExternalTrace() + { + // Arrange + var traceId = SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8"); + var externalContext = Substitute.For(); + externalContext.Snapshot().Returns(externalContext); + externalContext.TraceId.Returns(traceId); + externalContext.SpanId.Returns(SpanId.Parse("2000000000000000")); + externalContext.IsSampled.Returns((bool?)null); + _fixture.Options.ExternalPropagationContext = externalContext; + _fixture.Options.TracesSampleRate = 1.0; + var hub = _fixture.GetSut(); + + // Act + var transaction = hub.StartTransaction("name", "operation"); + + // Assert + transaction.TraceId.Should().Be(traceId); + transaction.ParentSpanId.Should().BeNull(); + transaction.IsSampled.Should().BeTrue(); + transaction.GetDynamicSamplingContext()!.Items["trace_id"].Should().Be(traceId.ToString()); + } + + [Fact] + public void StartTransaction_ContextWithParent_IgnoresExternalPropagationContext() + { + // Arrange + var externalContext = Substitute.For(); + externalContext.Snapshot().Returns(externalContext); + externalContext.TraceId.Returns(SentryId.Parse("75302ac48a024bde9a3b3734a82e36c8")); + externalContext.SpanId.Returns(SpanId.Parse("2000000000000000")); + externalContext.IsSampled.Returns(true); + _fixture.Options.ExternalPropagationContext = externalContext; + _fixture.Options.TracesSampleRate = 1.0; + var hub = _fixture.GetSut(); + var traceId = SentryId.Parse("5bd5f6d346b442dd9177dce9302fd737"); + var parentSpanId = SpanId.Parse("3000000000000000"); + var context = new TransactionContext("name", "operation", parentSpanId: parentSpanId, traceId: traceId); + + // Act + var transaction = hub.StartTransaction(context); + + // Assert + transaction.TraceId.Should().Be(traceId); + transaction.ParentSpanId.Should().Be(parentSpanId); + } + [Fact] public void ContinueTrace_ReceivesHeaders_SetsPropagationContextAndReturnsTransactionContext() {