diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a3af4f7c..11f2237078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Update `sample_rate` of _Dynamic Sampling Context (DSC)_ when making sampling decisions ([#4374](https://github.com/getsentry/sentry-dotnet/pull/4374)) + ## 5.13.0 ### Features @@ -1252,7 +1258,7 @@ There are some functional differences when publishing Native AOT: ### Fixes -- Resolved an isse where the SDK would throw an exception while attempting to set the DynamicSamplingContext but the context exists already. ([#2592](https://github.com/getsentry/sentry-dotnet/pull/2592)) +- Resolved an issue where the SDK would throw an exception while attempting to set the DynamicSamplingContext but the context exists already. ([#2592](https://github.com/getsentry/sentry-dotnet/pull/2592)) ### Dependencies diff --git a/src/Sentry/DynamicSamplingContext.cs b/src/Sentry/DynamicSamplingContext.cs index 7f1e1b82ba..e81550dadc 100644 --- a/src/Sentry/DynamicSamplingContext.cs +++ b/src/Sentry/DynamicSamplingContext.cs @@ -98,6 +98,24 @@ private DynamicSamplingContext(SentryId traceId, public BaggageHeader ToBaggageHeader() => BaggageHeader.Create(Items, useSentryPrefix: true); + public DynamicSamplingContext WithSampleRate(double sampleRate) + { + if (Items.TryGetValue("sample_rate", out var dscSampleRate)) + { + if (double.TryParse(dscSampleRate, NumberStyles.Float, CultureInfo.InvariantCulture, out var rate)) + { + if (Math.Abs(rate - sampleRate) > double.Epsilon) + { + var items = Items.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + items["sample_rate"] = sampleRate.ToString(CultureInfo.InvariantCulture); + return new DynamicSamplingContext(items); + } + } + } + + return this; + } + public DynamicSamplingContext WithReplayId(IReplaySession? replaySession) { if (replaySession?.ActiveReplayId is not { } replayId || replayId == SentryId.Empty) diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index 9c61f62228..629e96128a 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -173,8 +173,8 @@ internal ITransactionTracer StartTransaction( bool? isSampled = null; double? sampleRate = null; - var sampleRand = dynamicSamplingContext?.Items.TryGetValue("sample_rand", out var dscsampleRand) ?? false - ? double.Parse(dscsampleRand, NumberStyles.Float, CultureInfo.InvariantCulture) + var sampleRand = dynamicSamplingContext?.Items.TryGetValue("sample_rand", out var dscSampleRand) ?? false + ? double.Parse(dscSampleRand, NumberStyles.Float, CultureInfo.InvariantCulture) : SampleRandHelper.GenerateSampleRand(context.TraceId.ToString()); // TracesSampler runs regardless of whether a decision has already been made, as it can be used to override it. @@ -188,14 +188,26 @@ internal ITransactionTracer StartTransaction( { // The TracesSampler trumps all other sampling decisions (even the trace header) sampleRate = samplerSampleRate; - isSampled = SampleRandHelper.IsSampled(sampleRand, sampleRate.Value); + isSampled = SampleRandHelper.IsSampled(sampleRand, samplerSampleRate); + + // Ensure the actual sampleRate is set on the provided DSC (if any) when the TracesSampler reached a sampling decision + dynamicSamplingContext = dynamicSamplingContext?.WithSampleRate(samplerSampleRate); } } // 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); + if (isSampled == null) + { + sampleRate = _options.TracesSampleRate ?? 0.0; + isSampled = context.IsSampled ?? SampleRandHelper.IsSampled(sampleRand, sampleRate.Value); + + if (context.IsSampled is null && _options.TracesSampleRate is not null) + { + // Ensure the actual sampleRate is set on the provided DSC (if any) when not IsSampled upstream but the TracesSampleRate reached a sampling decision + dynamicSamplingContext = dynamicSamplingContext?.WithSampleRate(_options.TracesSampleRate.Value); + } + } // Make sure there is a replayId (if available) on the provided DSC (if any). dynamicSamplingContext = dynamicSamplingContext?.WithReplayId(_replaySession); diff --git a/src/Sentry/TransactionTracer.cs b/src/Sentry/TransactionTracer.cs index 5966a224da..767ccf977c 100644 --- a/src/Sentry/TransactionTracer.cs +++ b/src/Sentry/TransactionTracer.cs @@ -226,7 +226,6 @@ internal TransactionTracer(IHub hub, string name, string operation, TransactionN /// internal TransactionTracer(IHub hub, ITransactionContext context, TimeSpan? idleTimeout = null) { - Debug.Assert(context.IsSampled ?? true, "context.IsSampled should always be true when creating a TransactionTracer"); _hub = hub; _options = _hub.GetSentryOptions(); Name = context.Name; diff --git a/test/Sentry.AspNetCore.Tests/SentryTracingMiddlewareTests.cs b/test/Sentry.AspNetCore.Tests/SentryTracingMiddlewareTests.cs index f20b4cf1c5..3241942048 100644 --- a/test/Sentry.AspNetCore.Tests/SentryTracingMiddlewareTests.cs +++ b/test/Sentry.AspNetCore.Tests/SentryTracingMiddlewareTests.cs @@ -316,7 +316,7 @@ public async Task Baggage_header_propagates_to_outbound_requests(bool shouldProp "sentry-trace_id=75302ac48a024bde9a3b3734a82e36c8, " + "sentry-public_key=d4d82fc1c2c4032a83f3a29aa3a3aff, " + "sentry-sample_rand=0.1234, " + - "sentry-sample_rate=0.5"; + "sentry-sample_rate=1"; } else { @@ -395,13 +395,18 @@ public async Task Baggage_header_propagates_to_outbound_requests(bool shouldProp [Fact] public async Task Baggage_header_sets_dynamic_sampling_context() { - // incoming baggage header - const string baggage = + const string incomingBaggageHeader = "sentry-trace_id=75302ac48a024bde9a3b3734a82e36c8, " + "sentry-public_key=d4d82fc1c2c4032a83f3a29aa3a3aff, " + "sentry-sample_rand=0.1234, " + "sentry-sample_rate=0.5"; + const string expectedBaggageHeader = + "sentry-trace_id=75302ac48a024bde9a3b3734a82e36c8, " + + "sentry-public_key=d4d82fc1c2c4032a83f3a29aa3a3aff, " + + "sentry-sample_rand=0.1234, " + + "sentry-sample_rate=1"; + // Arrange TransactionTracer transaction = null; @@ -440,7 +445,7 @@ public async Task Baggage_header_sets_dynamic_sampling_context() { Headers = { - {"baggage", baggage} + {"baggage", incomingBaggageHeader} } }; @@ -449,7 +454,7 @@ public async Task Baggage_header_sets_dynamic_sampling_context() // Assert var dsc = transaction?.DynamicSamplingContext; Assert.NotNull(dsc); - Assert.Equal(baggage, dsc.ToBaggageHeader().ToString()); + Assert.Equal(expectedBaggageHeader, dsc.ToBaggageHeader().ToString()); } [Fact] diff --git a/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs b/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs index 360d3a6eed..c97e15f6a6 100644 --- a/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs +++ b/test/Sentry.OpenTelemetry.Tests/SentrySpanProcessorTests.cs @@ -141,7 +141,8 @@ public void OnStart_Transaction_With_DynamicSamplingContext() { actual.Items["trace_id"].Should().Be(expected["trace_id"]); actual.Items["public_key"].Should().Be(expected["public_key"]); - actual.Items["sample_rate"].Should().Be(expected["sample_rate"]); + actual.Items["sample_rate"].Should().NotBe(expected["sample_rate"]); + actual.Items["sample_rate"].Should().Be(_fixture.Options.TracesSampleRate.ToString()); } } diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index acd9eef972..91db9f4956 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -65,14 +65,21 @@ public void PushAndLockScope_DoesNotAffectOuterScope() // Arrange var hub = _fixture.GetSut(); - // Act & assert - hub.ConfigureScope(s => Assert.False(s.Locked)); + // Act & Assert + hub.ScopeManager.ConfigureScope(s => Assert.False(s.Locked)); using (hub.PushAndLockScope()) { - hub.ConfigureScope(s => Assert.True(s.Locked)); + hub.ScopeManager.ConfigureScope(s => Assert.True(s.Locked)); } - hub.ConfigureScope(s => Assert.False(s.Locked)); + if (_fixture.Options.IsGlobalModeEnabled) + { + hub.ScopeManager.ConfigureScope(s => Assert.True(s.Locked)); + } + else + { + hub.ScopeManager.ConfigureScope(s => Assert.False(s.Locked)); + } } [Fact] @@ -173,14 +180,13 @@ public void CaptureException_TransactionFinished_Gets_DSC_From_LinkedSpan() {"sentry-sample_rate", "1.0"}, {"sentry-trace_id", "75302ac48a024bde9a3b3734a82e36c8"}, {"sentry-public_key", "d4d82fc1c2c4032a83f3a29aa3a3aff"}, - {"sentry-replay_id","bfd31b89a59d41c99d96dc2baf840ecd"} + {"sentry-replay_id", "bfd31b89a59d41c99d96dc2baf840ecd"} }).CreateDynamicSamplingContext(_fixture.ReplaySession); var transaction = hub.StartTransaction( transactionContext, new Dictionary(), - dsc - ); + dsc); transaction.Finish(exception); // Act @@ -681,6 +687,102 @@ public void StartTransaction_SameInstrumenter_SampledIn() transaction.IsSampled.Should().BeTrue(); } + [Fact] + public void StartTransaction_DynamicSamplingContextWithSampleRate_UsesSampleRate() + { + // Arrange + var transactionContext = new TransactionContext("name", "operation"); + var dsc = BaggageHeader.Create(new List> + { + {"sentry-trace_id", "43365712692146d08ee11a729dfbcaca"}, + {"sentry-public_key", "d4d82fc1c2c4032a83f3a29aa3a3aff"}, + {"sentry-sample_rate", "0.5"}, + {"sentry-sample_rand", "0.1234"}, + }).CreateDynamicSamplingContext(); + + _fixture.Options.TracesSampler = _ => 0.5; + _fixture.Options.TracesSampleRate = 0.5; + + var hub = _fixture.GetSut(); + + // Act + var transaction = hub.StartTransaction(transactionContext, new Dictionary(), dsc); + + // Assert + var transactionTracer = transaction.Should().BeOfType().Subject; + transactionTracer.SampleRate.Should().Be(0.5); + transactionTracer.DynamicSamplingContext.Should().BeSameAs(dsc); + } + + // overwrite the 'sample_rate' of the Dynamic Sampling Context (DSC) when a sampling decisions is made in the downstream SDK + // 1. overwrite when 'TracesSampler' reaches a sampling decision + // 2. keep when a sampling decision has been made upstream (via 'TransactionContext.IsSampled') + // 3. overwrite when 'TracesSampleRate' reaches a sampling decision + // 4. keep otherwise + [SkippableTheory] + [InlineData(null, 0.3, 0.4, true, 0.3, true)] + [InlineData(null, 0.3, null, true, 0.3, true)] + [InlineData(null, null, 0.4, true, 0.4, true)] + [InlineData(null, null, null, false, 0.0, false)] + [InlineData(true, 0.3, 0.4, true, 0.3, true)] + [InlineData(true, 0.3, null, true, 0.3, true)] + [InlineData(true, null, 0.4, true, 0.4, false)] + [InlineData(true, null, null, true, 0.0, false)] + [InlineData(false, 0.3, 0.4, true, 0.3, true)] + [InlineData(false, 0.3, null, true, 0.3, true)] + [InlineData(false, null, 0.4, false, 0.4, false)] + [InlineData(false, null, null, false, 0.0, false)] + public void StartTransaction_DynamicSamplingContextWithSampleRate_OverwritesSampleRate(bool? isSampled, double? tracesSampler, double? tracesSampleRate, bool expectedIsSampled, double expectedSampleRate, bool expectedDscOverwritten) + { + // Arrange + var transactionContext = new TransactionContext("name", "operation", isSampled: isSampled); + var dsc = BaggageHeader.Create(new List> + { + {"sentry-trace_id", "43365712692146d08ee11a729dfbcaca"}, + {"sentry-public_key", "d4d82fc1c2c4032a83f3a29aa3a3aff"}, + {"sentry-sample_rate", "0.5"}, + {"sentry-sample_rand", "0.1234"}, + }).CreateDynamicSamplingContext(); + + _fixture.Options.TracesSampler = _ => tracesSampler; + _fixture.Options.TracesSampleRate = tracesSampleRate; + + var hub = _fixture.GetSut(); + + // Act + var transaction = hub.StartTransaction(transactionContext, new Dictionary(), dsc); + + // Assert + if (expectedIsSampled) + { + var transactionTracer = transaction.Should().BeOfType().Subject; + transactionTracer.SampleRate.Should().Be(expectedSampleRate); + if (expectedDscOverwritten) + { + transactionTracer.DynamicSamplingContext.Should().NotBeSameAs(dsc); + transactionTracer.DynamicSamplingContext.Should().BeEquivalentTo(dsc.ReplaceSampleRate(expectedSampleRate)); + } + else + { + transactionTracer.DynamicSamplingContext.Should().BeSameAs(dsc); + } + } + else + { + var unsampledTransaction = transaction.Should().BeOfType().Subject; + unsampledTransaction.SampleRate.Should().Be(expectedSampleRate); + if (expectedDscOverwritten) + { + unsampledTransaction.DynamicSamplingContext.Should().NotBeSameAs(dsc); + unsampledTransaction.DynamicSamplingContext.Should().BeEquivalentTo(dsc.ReplaceSampleRate(expectedSampleRate)); + } + else + { + unsampledTransaction.DynamicSamplingContext.Should().BeSameAs(dsc); + } + } + } + [Theory] [InlineData(true)] [InlineData(false)] @@ -697,7 +799,7 @@ public void StartTransaction_DynamicSamplingContextWithReplayId_UsesActiveReplay {"sentry-public_key", "d4d82fc1c2c4032a83f3a29aa3a3aff"}, {"sentry-sampled", "true"}, {"sentry-sample_rate", "0.5"}, // Required in the baggage header, but ignored by sampling logic - {"sentry-replay_id","bfd31b89a59d41c99d96dc2baf840ecd"} + {"sentry-replay_id", "bfd31b89a59d41c99d96dc2baf840ecd"} }).CreateDynamicSamplingContext(dummyReplaySession); _fixture.Options.TracesSampleRate = 1.0; @@ -708,25 +810,18 @@ public void StartTransaction_DynamicSamplingContextWithReplayId_UsesActiveReplay var transaction = hub.StartTransaction(transactionContext, new Dictionary(), dsc); // Assert - var transactionTracer = ((TransactionTracer)transaction); - transactionTracer.IsSampled.Should().Be(true); + var transactionTracer = transaction.Should().BeOfType().Subject; + transactionTracer.IsSampled.Should().BeTrue(); transactionTracer.DynamicSamplingContext.Should().NotBeNull(); - foreach (var dscItem in dsc!.Items) + + var expectedDsc = dsc.ReplaceSampleRate(_fixture.Options.TracesSampleRate.Value); + if (replaySessionIsActive) { - if (dscItem.Key == "replay_id") - { - transactionTracer.DynamicSamplingContext!.Items["replay_id"].Should().Be(replaySessionIsActive - // We overwrite the replay_id when we have an active replay session - ? _fixture.ReplaySession.ActiveReplayId.ToString() - // Otherwise we propagate whatever was in the baggage header - : dscItem.Value); - } - else - { - transactionTracer.DynamicSamplingContext!.Items.Should() - .Contain(kvp => kvp.Key == dscItem.Key && kvp.Value == dscItem.Value); - } + // We overwrite the replay_id when we have an active replay session + // Otherwise we propagate whatever was in the baggage header + expectedDsc = expectedDsc.ReplaceReplayId(_fixture.ReplaySession); } + transactionTracer.DynamicSamplingContext.Should().BeEquivalentTo(expectedDsc); } [Theory] @@ -743,7 +838,7 @@ public void StartTransaction_NoDynamicSamplingContext_UsesActiveReplaySessionId( var transaction = hub.StartTransaction(transactionContext, new Dictionary()); // Assert - var transactionTracer = ((TransactionTracer)transaction); + var transactionTracer = transaction.Should().BeOfType().Subject; transactionTracer.SampleRand.Should().NotBeNull(); transactionTracer.DynamicSamplingContext.Should().NotBeNull(); if (replaySessionIsActive) @@ -770,7 +865,7 @@ public void StartTransaction_NoDynamicSamplingContext_GeneratesSampleRand() var transaction = hub.StartTransaction(transactionContext, customContext); // Assert - var transactionTracer = ((TransactionTracer)transaction); + var transactionTracer = transaction.Should().BeOfType().Subject; transactionTracer.SampleRand.Should().NotBeNull(); transactionTracer.DynamicSamplingContext.Should().NotBeNull(); transactionTracer.DynamicSamplingContext!.Items.Should().ContainKey("sample_rand"); @@ -789,7 +884,7 @@ public void StartTransaction_DynamicSamplingContextWithoutSampleRand_SampleRandN var transaction = hub.StartTransaction(transactionContext, new Dictionary(), DynamicSamplingContext.Empty); // Assert - var transactionTracer = ((TransactionTracer)transaction); + var transactionTracer = transaction.Should().BeOfType().Subject; transactionTracer.SampleRand.Should().NotBeNull(); transactionTracer.DynamicSamplingContext.Should().NotBeNull(); // See https://develop.sentry.dev/sdk/telemetry/traces/dynamic-sampling-context/#freezing-dynamic-sampling-context @@ -818,11 +913,12 @@ public void StartTransaction_DynamicSamplingContextWithSampleRand_InheritsSample var transaction = hub.StartTransaction(transactionContext, new Dictionary(), dsc); // Assert - var transactionTracer = ((TransactionTracer)transaction); - transactionTracer.IsSampled.Should().Be(true); + var transactionTracer = transaction.Should().BeOfType().Subject; + transactionTracer.IsSampled.Should().BeTrue(); transactionTracer.SampleRate.Should().Be(0.4); transactionTracer.SampleRand.Should().Be(0.1234); - transactionTracer.DynamicSamplingContext.Should().Be(dsc); + transactionTracer.DynamicSamplingContext.Should().NotBeSameAs(dsc); + transactionTracer.DynamicSamplingContext.Should().BeEquivalentTo(dsc.ReplaceSampleRate(0.4)); } [Theory] @@ -851,20 +947,21 @@ public void StartTransaction_TraceSampler_UsesSampleRand(double sampleRate, bool // Assert if (expectedIsSampled) { - transaction.Should().BeOfType(); - var transactionTracer = ((TransactionTracer)transaction); - transactionTracer.IsSampled.Should().Be(true); + var transactionTracer = transaction.Should().BeOfType().Subject; + transactionTracer.IsSampled.Should().BeTrue(); transactionTracer.SampleRate.Should().Be(sampleRate); transactionTracer.SampleRand.Should().Be(0.1234); - transactionTracer.DynamicSamplingContext.Should().Be(dsc); + transactionTracer.DynamicSamplingContext.Should().NotBeSameAs(dsc); + transactionTracer.DynamicSamplingContext.Should().BeEquivalentTo(dsc.ReplaceSampleRate(sampleRate)); } else { - transaction.Should().BeOfType(); - var unsampledTransaction = ((UnsampledTransaction)transaction); + var unsampledTransaction = transaction.Should().BeOfType().Subject; + unsampledTransaction.IsSampled.Should().BeFalse(); unsampledTransaction.SampleRate.Should().Be(sampleRate); unsampledTransaction.SampleRand.Should().Be(0.1234); - unsampledTransaction.DynamicSamplingContext.Should().Be(dsc); + unsampledTransaction.DynamicSamplingContext.Should().NotBeSameAs(dsc); + unsampledTransaction.DynamicSamplingContext.Should().BeEquivalentTo(dsc.ReplaceSampleRate(sampleRate)); } } @@ -894,20 +991,21 @@ public void StartTransaction_StaticSampler_UsesSampleRand(double sampleRate, boo // Assert if (expectedIsSampled) { - transaction.Should().BeOfType(); - var transactionTracer = ((TransactionTracer)transaction); - transactionTracer.IsSampled.Should().Be(expectedIsSampled); + var transactionTracer = transaction.Should().BeOfType().Subject; + transactionTracer.IsSampled.Should().BeTrue(); transactionTracer.SampleRate.Should().Be(sampleRate); transactionTracer.SampleRand.Should().Be(0.1234); - transactionTracer.DynamicSamplingContext.Should().Be(dsc); + transactionTracer.DynamicSamplingContext.Should().NotBeSameAs(dsc); + transactionTracer.DynamicSamplingContext.Should().BeEquivalentTo(dsc.ReplaceSampleRate(sampleRate)); } else { - transaction.Should().BeOfType(); - var unsampledTransaction = ((UnsampledTransaction)transaction); + var unsampledTransaction = transaction.Should().BeOfType().Subject; + unsampledTransaction.IsSampled.Should().BeFalse(); unsampledTransaction.SampleRate.Should().Be(sampleRate); unsampledTransaction.SampleRand.Should().Be(0.1234); - unsampledTransaction.DynamicSamplingContext.Should().Be(dsc); + unsampledTransaction.DynamicSamplingContext.Should().NotBeSameAs(dsc); + unsampledTransaction.DynamicSamplingContext.Should().BeEquivalentTo(dsc.ReplaceSampleRate(sampleRate)); } } @@ -1153,9 +1251,13 @@ public void GetBaggage_SpanActive_ReturnsBaggageFromSpan(bool isSampled) // Assert baggage.Should().NotBeNull(); - Assert.Equal("43365712692146d08ee11a729dfbcaca", Assert.Contains("trace_id", dsc.Items)); - Assert.Equal("d4d82fc1c2c4032a83f3a29aa3a3aff", Assert.Contains("public_key", dsc.Items)); - Assert.Equal("0.0", Assert.Contains("sample_rate", dsc.Items)); + var sampleRand = isSampled ? ((TransactionTracer)transaction).SampleRand : ((UnsampledTransaction)transaction).SampleRand; + baggage.Members.Should().Equal([ + new KeyValuePair("sentry-trace_id", "43365712692146d08ee11a729dfbcaca"), + new KeyValuePair("sentry-public_key", "d4d82fc1c2c4032a83f3a29aa3a3aff"), + new KeyValuePair("sentry-sample_rate", isSampled ? "1" : "0.0"), + new KeyValuePair("sentry-sample_rand", sampleRand!.Value.ToString(CultureInfo.InvariantCulture)), + ]); } [Fact] @@ -1172,11 +1274,11 @@ public void GetBaggage_NoSpanActive_ReturnsBaggageFromPropagationContext() // Assert baggage.Should().NotBeNull(); - Assert.Contains("43365712692146d08ee11a729dfbcaca", baggage!.ToString()); + Assert.Contains("sentry-trace_id=43365712692146d08ee11a729dfbcaca", baggage!.ToString()); } [Fact] - public void ContinueTrace_SetsPropagationContextAndReturnsTransactionContext() + public void ContinueTrace_ReceivesHeaders_SetsPropagationContextAndReturnsTransactionContext() { // Arrange var hub = _fixture.GetSut(); @@ -1193,23 +1295,49 @@ public void ContinueTrace_SetsPropagationContextAndReturnsTransactionContext() {"sentry-sample_rate", "1.0"} }); - hub.ConfigureScope(scope => scope.PropagationContext.TraceId.Should().Be("43365712692146d08ee11a729dfbcaca")); // Sanity check + hub.ScopeManager.ConfigureScope(scope => scope.PropagationContext.TraceId.Should().Be(SentryId.Parse("43365712692146d08ee11a729dfbcaca"))); // Sanity check // Act var transactionContext = hub.ContinueTrace(traceHeader, baggageHeader, "test-name"); // Assert - hub.ConfigureScope(scope => + hub.ScopeManager.ConfigureScope(scope => { scope.PropagationContext.TraceId.Should().Be(SentryId.Parse("5bd5f6d346b442dd9177dce9302fd737")); scope.PropagationContext.ParentSpanId.Should().Be(SpanId.Parse("2000000000000000")); Assert.NotNull(scope.PropagationContext._dynamicSamplingContext); + scope.PropagationContext._dynamicSamplingContext.Items.Should().Contain(baggageHeader.GetSentryMembers()); }); transactionContext.TraceId.Should().Be(SentryId.Parse("5bd5f6d346b442dd9177dce9302fd737")); transactionContext.ParentSpanId.Should().Be(SpanId.Parse("2000000000000000")); } + [Fact] + public void ContinueTrace_DoesNotReceiveHeaders_CreatesRootTrace() + { + // Arrange + var hub = _fixture.GetSut(); + + // Act + var transactionContext = hub.ContinueTrace((SentryTraceHeader)null, (BaggageHeader)null, "test-name", "test-operation"); + + // Assert + hub.ScopeManager.ConfigureScope(scope => + { + Assert.Null(scope.PropagationContext.ParentSpanId); + Assert.Null(scope.PropagationContext._dynamicSamplingContext); + }); + + transactionContext.Name.Should().Be("test-name"); + transactionContext.Operation.Should().Be("test-operation"); + transactionContext.SpanId.Should().NotBeNull(); + transactionContext.ParentSpanId.Should().BeNull(); + transactionContext.TraceId.Should().NotBeNull(); + transactionContext.IsSampled.Should().BeNull(); + transactionContext.IsParentSampled.Should().BeNull(); + } + [Fact] public void ContinueTrace_ReceivesHeadersAsStrings_SetsPropagationContextAndReturnsTransactionContext() { @@ -1221,17 +1349,18 @@ public void ContinueTrace_ReceivesHeadersAsStrings_SetsPropagationContextAndRetu var traceHeader = "5bd5f6d346b442dd9177dce9302fd737-2000000000000000"; var baggageHeader = "sentry-trace_id=5bd5f6d346b442dd9177dce9302fd737, sentry-public_key=49d0f7386ad645858ae85020e393bef3, sentry-sample_rate=1.0"; - hub.ConfigureScope(scope => scope.PropagationContext.TraceId.Should().Be("43365712692146d08ee11a729dfbcaca")); // Sanity check + hub.ScopeManager.ConfigureScope(scope => scope.PropagationContext.TraceId.Should().Be(SentryId.Parse("43365712692146d08ee11a729dfbcaca"))); // Sanity check // Act var transactionContext = hub.ContinueTrace(traceHeader, baggageHeader, "test-name"); // Assert - hub.ConfigureScope(scope => + hub.ScopeManager.ConfigureScope(scope => { scope.PropagationContext.TraceId.Should().Be(SentryId.Parse("5bd5f6d346b442dd9177dce9302fd737")); scope.PropagationContext.ParentSpanId.Should().Be(SpanId.Parse("2000000000000000")); Assert.NotNull(scope.PropagationContext._dynamicSamplingContext); + scope.PropagationContext._dynamicSamplingContext.ToBaggageHeader().Members.Should().Contain(BaggageHeader.TryParse(baggageHeader)!.Members); }); transactionContext.TraceId.Should().Be(SentryId.Parse("5bd5f6d346b442dd9177dce9302fd737")); @@ -1239,21 +1368,28 @@ public void ContinueTrace_ReceivesHeadersAsStrings_SetsPropagationContextAndRetu } [Fact] - public void ContinueTrace_DoesNotReceiveHeaders_CreatesRootTrace() + public void ContinueTrace_DoesNotReceiveHeadersAsStrings_CreatesRootTrace() { // Arrange var hub = _fixture.GetSut(); // Act - var transactionContext = hub.ContinueTrace((string)null, null, "test-name"); + var transactionContext = hub.ContinueTrace((string)null, (string)null, "test-name"); // Assert + hub.ScopeManager.ConfigureScope(scope => + { + Assert.Null(scope.PropagationContext.ParentSpanId); + Assert.Null(scope.PropagationContext._dynamicSamplingContext); + }); + transactionContext.Name.Should().Be("test-name"); - transactionContext.SpanId.Should().NotBe(null); - transactionContext.ParentSpanId.Should().Be(null); - transactionContext.TraceId.Should().NotBe(null); - transactionContext.IsSampled.Should().Be(null); - transactionContext.IsParentSampled.Should().Be(null); + transactionContext.Operation.Should().BeEmpty(); + transactionContext.SpanId.Should().NotBeNull(); + transactionContext.ParentSpanId.Should().BeNull(); + transactionContext.TraceId.Should().NotBeNull(); + transactionContext.IsSampled.Should().BeNull(); + transactionContext.IsParentSampled.Should().BeNull(); } [Fact] @@ -1269,7 +1405,7 @@ public void CaptureTransaction_AfterTransactionFinishes_ResetsTransactionOnScope transaction.Finish(); // Assert - hub.ConfigureScope(scope => scope.Transaction.Should().BeNull()); + hub.ScopeManager.ConfigureScope(scope => scope.Transaction.Should().BeNull()); } #nullable enable @@ -2151,3 +2287,67 @@ internal partial class HubTestsJsonContext : JsonSerializerContext { } #endif + +#nullable enable +file static class DynamicSamplingContextExtensions +{ + public static DynamicSamplingContext ReplaceSampleRate(this DynamicSamplingContext? dsc, double sampleRate) + { + Assert.NotNull(dsc); + + var value = sampleRate.ToString(CultureInfo.InvariantCulture); + return dsc.Replace("sentry-sample_rate", value); + } + + public static DynamicSamplingContext ReplaceReplayId(this DynamicSamplingContext? dsc, IReplaySession replaySession) + { + Assert.NotNull(dsc); + + if (!replaySession.ActiveReplayId.HasValue) + { + throw new InvalidOperationException($"No {nameof(IReplaySession.ActiveReplayId)}."); + } + + var value = replaySession.ActiveReplayId.Value.ToString(); + return dsc.Replace("sentry-replay_id", value); + } + + private static DynamicSamplingContext Replace(this DynamicSamplingContext dsc, string key, string newValue) + { + var items = dsc.ToBaggageHeader().Members.ToList(); + var index = items.FindSingleIndex(key); + + var oldValue = items[index].Value; + if (oldValue == newValue) + { + throw new InvalidOperationException($"{key} already is {oldValue}."); + } + + items[index] = new KeyValuePair(key, newValue); + + var baggage = BaggageHeader.Create(items); + var dynamicSamplingContext = DynamicSamplingContext.CreateFromBaggageHeader(baggage, null); + if (dynamicSamplingContext is null) + { + throw new InvalidOperationException($"Invalid {nameof(BaggageHeader)}: {baggage}"); + } + + return dynamicSamplingContext; + } + + private static int FindSingleIndex(this List> items, string key) + { + var index = items.FindIndex(item => item.Key == key); + if (index == -1) + { + throw new InvalidOperationException($"{key} not found."); + } + + if (items.FindLastIndex(item => item.Key == key) != index) + { + throw new InvalidOperationException($"Duplicate {key} found."); + } + + return index; + } +}