From b1bc39017eb38b00af347f0b37ea062756bfc126 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:10:11 +0100 Subject: [PATCH 1/6] feat: Implement strict trace continuation Add org ID validation to distributed trace continuation to prevent traces from being continued across different Sentry organizations. - Parse org ID from DSN host (e.g., o1.ingest.us.sentry.io -> "1") - Add OrgId option to SentryOptions to override DSN-parsed value - Add StrictTraceContinuation bool option (default false) - Propagate sentry-org_id in outgoing baggage via DynamicSamplingContext - Validate org IDs in Hub.ContinueTrace: - Mismatched org IDs always start new trace (regardless of setting) - Missing incoming org_id + strict=true -> start new trace - Missing incoming org_id + strict=false -> continue trace (default) - Add comprehensive tests for all org ID validation scenarios - Update API approval snapshots Closes getsentry/sentry-dotnet#4963 Co-Authored-By: Claude Opus 4.6 --- src/Sentry/Dsn.cs | 26 +++- src/Sentry/DynamicSamplingContext.cs | 17 ++- src/Sentry/Internal/Hub.cs | 40 ++++++ src/Sentry/SentryOptions.cs | 40 ++++++ ...iApprovalTests.Run.DotNet10_0.verified.txt | 2 + ...piApprovalTests.Run.DotNet8_0.verified.txt | 2 + ...piApprovalTests.Run.DotNet9_0.verified.txt | 2 + .../ApiApprovalTests.Run.Net4_8.verified.txt | 2 + test/Sentry.Tests/HubTests.cs | 119 ++++++++++++++++++ test/Sentry.Tests/Protocol/DsnTests.cs | 35 ++++++ 10 files changed, 279 insertions(+), 6 deletions(-) diff --git a/src/Sentry/Dsn.cs b/src/Sentry/Dsn.cs index a132d89945..840cc0a396 100644 --- a/src/Sentry/Dsn.cs +++ b/src/Sentry/Dsn.cs @@ -38,13 +38,20 @@ internal sealed class Dsn /// private Uri ApiBaseUri { get; } + /// + /// The organization ID parsed from the DSN host (e.g., o1 in o1.ingest.us.sentry.io yields "1"). + /// Returns null if no org ID is present in the DSN. + /// + public string? OrgId { get; internal set; } + private Dsn( string source, string projectId, string? path, string? secretKey, string publicKey, - Uri apiBaseUri) + Uri apiBaseUri, + string? orgId = null) { Source = source; ProjectId = projectId; @@ -52,6 +59,7 @@ private Dsn( SecretKey = secretKey; PublicKey = publicKey; ApiBaseUri = apiBaseUri; + OrgId = orgId; } public Uri GetStoreEndpointUri() => new(ApiBaseUri, "store/"); @@ -95,6 +103,19 @@ public static Dsn Parse(string dsn) throw new ArgumentException("Invalid DSN: A Project Id is required."); } + // Parse org ID from host (e.g., "o1.ingest.us.sentry.io" -> "1") + string? orgId = null; + var hostParts = uri.DnsSafeHost.Split('.'); + if (hostParts.Length > 0) + { + var firstPart = hostParts[0]; + if (firstPart.Length >= 2 && firstPart[0] == 'o' && + ulong.TryParse(firstPart.Substring(1), out _)) + { + orgId = firstPart.Substring(1); + } + } + var apiBaseUri = new UriBuilder { Scheme = uri.Scheme, @@ -109,7 +130,8 @@ public static Dsn Parse(string dsn) path, secretKey, publicKey, - apiBaseUri); + apiBaseUri, + orgId); } public static Dsn? TryParse(string? dsn) diff --git a/src/Sentry/DynamicSamplingContext.cs b/src/Sentry/DynamicSamplingContext.cs index 992b2509b6..d020d618d1 100644 --- a/src/Sentry/DynamicSamplingContext.cs +++ b/src/Sentry/DynamicSamplingContext.cs @@ -29,7 +29,8 @@ private DynamicSamplingContext(SentryId traceId, string? release = null, string? environment = null, string? transactionName = null, - IReplaySession? replaySession = null) + IReplaySession? replaySession = null, + string? orgId = null) { // Validate and set required values if (traceId == SentryId.Empty) @@ -94,6 +95,11 @@ private DynamicSamplingContext(SentryId traceId, items.Add("replay_id", replayId.ToString()); } + if (!string.IsNullOrWhiteSpace(orgId)) + { + items.Add("org_id", orgId); + } + _items = items; } @@ -199,7 +205,8 @@ public static DynamicSamplingContext CreateFromTransaction(TransactionTracer tra release, environment, transactionName, - replaySession); + replaySession, + orgId: options.GetEffectiveOrgId()); } public static DynamicSamplingContext CreateFromUnsampledTransaction(UnsampledTransaction transaction, SentryOptions options, IReplaySession? replaySession) @@ -224,7 +231,8 @@ public static DynamicSamplingContext CreateFromUnsampledTransaction(UnsampledTra release, environment, transactionName, - replaySession); + replaySession, + orgId: options.GetEffectiveOrgId()); } public static DynamicSamplingContext CreateFromPropagationContext(SentryPropagationContext propagationContext, SentryOptions options, IReplaySession? replaySession) @@ -240,7 +248,8 @@ public static DynamicSamplingContext CreateFromPropagationContext(SentryPropagat null, release: release, environment: environment, - replaySession: replaySession + replaySession: replaySession, + orgId: options.GetEffectiveOrgId() ); } diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index 047f588dfd..dade93b89f 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -378,6 +378,13 @@ public TransactionContext ContinueTrace( string? name = null, string? operation = null) { + if (!ShouldContinueTrace(baggageHeader)) + { + _options.LogDebug("Not continuing trace due to org ID validation. Starting new trace."); + traceHeader = null; + baggageHeader = null; + } + var propagationContext = SentryPropagationContext.CreateFromHeaders(_options.DiagnosticLogger, traceHeader, baggageHeader, _replaySession); ConfigureScope(static (scope, propagationContext) => scope.SetPropagationContext(propagationContext), propagationContext); @@ -391,6 +398,39 @@ public TransactionContext ContinueTrace( isParentSampled: traceHeader?.IsSampled); } + internal bool ShouldContinueTrace(BaggageHeader? baggageHeader) + { + var sdkOrgId = _options.GetEffectiveOrgId(); + + string? baggageOrgId = null; + if (baggageHeader is not null) + { + var sentryMembers = baggageHeader.GetSentryMembers(); + sentryMembers.TryGetValue("org_id", out baggageOrgId); + } + + // Mismatched org IDs always cause a new trace, regardless of strict mode + if (!string.IsNullOrEmpty(sdkOrgId) && !string.IsNullOrEmpty(baggageOrgId) && sdkOrgId != baggageOrgId) + { + return false; + } + + // In strict mode, both must be present and match + if (_options.StrictTraceContinuation) + { + // If both are missing, continue (nothing to compare) + if (string.IsNullOrEmpty(sdkOrgId) && string.IsNullOrEmpty(baggageOrgId)) + { + return true; + } + + // Both must be present and equal + return sdkOrgId == baggageOrgId; + } + + return true; + } + public void StartSession() { // Attempt to recover persisted session left over from previous run diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index 95ce1dffa3..c844abcc0f 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -452,6 +452,24 @@ public string? Dsn internal Dsn? _parsedDsn; internal Dsn ParsedDsn => _parsedDsn ??= Sentry.Dsn.Parse(Dsn!); + /// + /// Returns the effective org ID, preferring if set, otherwise falling back to the DSN-parsed value. + /// + internal string? GetEffectiveOrgId() + { + if (!string.IsNullOrWhiteSpace(OrgId)) + { + return OrgId; + } + + if (!string.IsNullOrWhiteSpace(Dsn)) + { + return ParsedDsn.OrgId; + } + + return null; + } + private readonly Lazy _sentryBaseUrl; internal bool IsSentryRequest(string? requestUri) => @@ -1068,6 +1086,28 @@ public IList TracePropagationTargets /// public bool PropagateTraceparent { get; set; } + /// + /// Controls trace continuation from third-party services that happen to be instrumented by Sentry. + /// + /// + /// When enabled, the SDK will require org IDs from baggage to match for continuing the trace. + /// If the incoming trace does not contain an org ID and this option is true, a new trace will be started. + /// When disabled (default), incoming traces without org IDs will be continued as normal, + /// but mismatched org IDs will always cause a new trace to be started regardless of this setting. + /// + public bool StrictTraceContinuation { get; set; } + + /// + /// Configures the org ID used for trace propagation and features like . + /// + /// + /// In most cases the org ID is already parsed from the DSN (e.g., o1 in + /// https://key@o1.ingest.us.sentry.io/123 yields org ID "1"). + /// Use this option when non-standard Sentry DSNs are used, such as self-hosted or when using a local Relay. + /// When set, this value overrides the org ID parsed from the DSN. + /// + public string? OrgId { get; set; } + internal ITransactionProfilerFactory? TransactionProfilerFactory { get; set; } private StackTraceMode? _stackTraceMode; diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index ccf866768e..5b0c537022 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -825,6 +825,7 @@ namespace Sentry public int MaxCacheItems { get; set; } public int MaxQueueItems { get; set; } public Sentry.Extensibility.INetworkStatusListener? NetworkStatusListener { get; set; } + public string? OrgId { get; set; } public double? ProfilesSampleRate { get; set; } public bool PropagateTraceparent { get; set; } public string? Release { get; set; } @@ -840,6 +841,7 @@ namespace Sentry public System.TimeSpan ShutdownTimeout { get; set; } public string SpotlightUrl { get; set; } public Sentry.StackTraceMode StackTraceMode { get; set; } + public bool StrictTraceContinuation { get; set; } public System.Collections.Generic.IList TagFilters { get; set; } public System.Collections.Generic.IList TraceIgnoreStatusCodes { get; set; } public System.Collections.Generic.IList TracePropagationTargets { get; set; } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index ccf866768e..5b0c537022 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -825,6 +825,7 @@ namespace Sentry public int MaxCacheItems { get; set; } public int MaxQueueItems { get; set; } public Sentry.Extensibility.INetworkStatusListener? NetworkStatusListener { get; set; } + public string? OrgId { get; set; } public double? ProfilesSampleRate { get; set; } public bool PropagateTraceparent { get; set; } public string? Release { get; set; } @@ -840,6 +841,7 @@ namespace Sentry public System.TimeSpan ShutdownTimeout { get; set; } public string SpotlightUrl { get; set; } public Sentry.StackTraceMode StackTraceMode { get; set; } + public bool StrictTraceContinuation { get; set; } public System.Collections.Generic.IList TagFilters { get; set; } public System.Collections.Generic.IList TraceIgnoreStatusCodes { get; set; } public System.Collections.Generic.IList TracePropagationTargets { get; set; } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index ccf866768e..5b0c537022 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -825,6 +825,7 @@ namespace Sentry public int MaxCacheItems { get; set; } public int MaxQueueItems { get; set; } public Sentry.Extensibility.INetworkStatusListener? NetworkStatusListener { get; set; } + public string? OrgId { get; set; } public double? ProfilesSampleRate { get; set; } public bool PropagateTraceparent { get; set; } public string? Release { get; set; } @@ -840,6 +841,7 @@ namespace Sentry public System.TimeSpan ShutdownTimeout { get; set; } public string SpotlightUrl { get; set; } public Sentry.StackTraceMode StackTraceMode { get; set; } + public bool StrictTraceContinuation { get; set; } public System.Collections.Generic.IList TagFilters { get; set; } public System.Collections.Generic.IList TraceIgnoreStatusCodes { get; set; } public System.Collections.Generic.IList TracePropagationTargets { get; set; } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 1fc1e68f4b..f9068edea7 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -812,6 +812,7 @@ namespace Sentry public int MaxCacheItems { get; set; } public int MaxQueueItems { get; set; } public Sentry.Extensibility.INetworkStatusListener? NetworkStatusListener { get; set; } + public string? OrgId { get; set; } public double? ProfilesSampleRate { get; set; } public bool PropagateTraceparent { get; set; } public string? Release { get; set; } @@ -827,6 +828,7 @@ namespace Sentry public System.TimeSpan ShutdownTimeout { get; set; } public string SpotlightUrl { get; set; } public Sentry.StackTraceMode StackTraceMode { get; set; } + public bool StrictTraceContinuation { get; set; } public System.Collections.Generic.IList TagFilters { get; set; } public System.Collections.Generic.IList TraceIgnoreStatusCodes { get; set; } public System.Collections.Generic.IList TracePropagationTargets { get; set; } diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index 9d8feed513..352b591df2 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -2807,6 +2807,125 @@ public void Dispose_CalledMultipleTimes_CleanupCalledOnlyOnce() // Assert integration.Disposed.Should().Be(1); } + + [Theory] + // strict=false: matching org IDs -> continue + [InlineData(false, "1", "1", true)] + // strict=false: no incoming org ID -> continue (permissive) + [InlineData(false, "1", null, true)] + // strict=false: incoming org ID but no SDK org ID -> continue (permissive) + [InlineData(false, null, "1", true)] + // strict=false: both missing -> continue + [InlineData(false, null, null, true)] + // strict=false: mismatched org IDs -> new trace (always) + [InlineData(false, "1", "2", false)] + // strict=true: matching org IDs -> continue + [InlineData(true, "1", "1", true)] + // strict=true: no incoming org ID -> new trace (strict requires match) + [InlineData(true, "1", null, false)] + // strict=true: incoming org ID but no SDK org ID -> new trace (strict requires match) + [InlineData(true, null, "1", false)] + // strict=true: both missing -> continue (nothing to compare) + [InlineData(true, null, null, true)] + // strict=true: mismatched org IDs -> new trace + [InlineData(true, "1", "2", false)] + public void ContinueTrace_StrictTraceContinuation_ValidatesOrgId( + bool strict, string? sdkOrgId, string? baggageOrgId, bool expectContinued) + { + // Arrange + var incomingTraceId = SentryId.Parse("bc6d53f15eb88f4320054569b8c553d4"); + + _fixture.Options.StrictTraceContinuation = strict; + _fixture.Options.OrgId = sdkOrgId; + + var hub = _fixture.GetSut(); + + var traceHeader = new SentryTraceHeader(incomingTraceId, SpanId.Parse("b72fa28504b07285"), true); + + var baggageMembers = new List> + { + { "sentry-trace_id", "bc6d53f15eb88f4320054569b8c553d4" }, + { "sentry-public_key", "49d0f7386ad645858ae85020e393bef3" }, + { "sentry-sample_rate", "1.0" } + }; + if (baggageOrgId is not null) + { + baggageMembers.Add(new KeyValuePair("sentry-org_id", baggageOrgId)); + } + var baggageHeader = BaggageHeader.Create(baggageMembers); + + // Act + var transactionContext = hub.ContinueTrace(traceHeader, baggageHeader, "test-name"); + + // Assert + if (expectContinued) + { + transactionContext.TraceId.Should().Be(incomingTraceId, + "trace should be continued when org IDs match or validation passes"); + } + else + { + transactionContext.TraceId.Should().NotBe(incomingTraceId, + "a new trace should be started when org ID validation fails"); + } + } + + [Fact] + public void ContinueTrace_OrgIdFromDsn_IsUsedForValidation() + { + // Arrange - DSN with org ID "1" in the subdomain + _fixture.Options.Dsn = "https://key@o1.ingest.us.sentry.io/123"; + _fixture.Options.StrictTraceContinuation = true; + + var hub = _fixture.GetSut(); + + var incomingTraceId = SentryId.Parse("bc6d53f15eb88f4320054569b8c553d4"); + var traceHeader = new SentryTraceHeader(incomingTraceId, SpanId.Parse("b72fa28504b07285"), true); + + // Baggage with matching org_id=1 + var baggageHeader = BaggageHeader.Create(new List> + { + { "sentry-trace_id", "bc6d53f15eb88f4320054569b8c553d4" }, + { "sentry-public_key", "49d0f7386ad645858ae85020e393bef3" }, + { "sentry-sample_rate", "1.0" }, + { "sentry-org_id", "1" } + }); + + // Act + var transactionContext = hub.ContinueTrace(traceHeader, baggageHeader, "test-name"); + + // Assert - should continue because org IDs match + transactionContext.TraceId.Should().Be(incomingTraceId); + } + + [Fact] + public void ContinueTrace_OrgIdOptionOverridesDsn() + { + // Arrange - DSN has org ID "1", but OrgId option overrides to "2" + _fixture.Options.Dsn = "https://key@o1.ingest.us.sentry.io/123"; + _fixture.Options.OrgId = "2"; + _fixture.Options.StrictTraceContinuation = false; + + var hub = _fixture.GetSut(); + + var incomingTraceId = SentryId.Parse("bc6d53f15eb88f4320054569b8c553d4"); + var traceHeader = new SentryTraceHeader(incomingTraceId, SpanId.Parse("b72fa28504b07285"), true); + + // Baggage with org_id=1 (matches DSN but not the override) + var baggageHeader = BaggageHeader.Create(new List> + { + { "sentry-trace_id", "bc6d53f15eb88f4320054569b8c553d4" }, + { "sentry-public_key", "49d0f7386ad645858ae85020e393bef3" }, + { "sentry-sample_rate", "1.0" }, + { "sentry-org_id", "1" } + }); + + // Act + var transactionContext = hub.ContinueTrace(traceHeader, baggageHeader, "test-name"); + + // Assert - should NOT continue because OrgId override (2) != baggage org_id (1) + transactionContext.TraceId.Should().NotBe(incomingTraceId); + } } #if NET6_0_OR_GREATER diff --git a/test/Sentry.Tests/Protocol/DsnTests.cs b/test/Sentry.Tests/Protocol/DsnTests.cs index d9eb15349c..70960c5a7d 100644 --- a/test/Sentry.Tests/Protocol/DsnTests.cs +++ b/test/Sentry.Tests/Protocol/DsnTests.cs @@ -277,6 +277,41 @@ public override string ToString() public static implicit operator Uri(DsnTestCase @case) => new($"{@case.Scheme}://{@case.Host}:{@case.Port}{@case.Path}/api/{@case.ProjectId}/store/"); } + [Fact] + public void Parse_DsnWithOrgId_ParsesOrgId() + { + var dsn = Dsn.Parse("https://key@o1.ingest.us.sentry.io/123"); + Assert.Equal("1", dsn.OrgId); + } + + [Fact] + public void Parse_DsnWithLargeOrgId_ParsesOrgId() + { + var dsn = Dsn.Parse("https://key@o123456.ingest.us.sentry.io/123"); + Assert.Equal("123456", dsn.OrgId); + } + + [Fact] + public void Parse_DsnWithoutOrgId_OrgIdIsNull() + { + var dsn = Dsn.Parse("https://key@sentry.io/123"); + Assert.Null(dsn.OrgId); + } + + [Fact] + public void Parse_DsnWithNonNumericOrgId_OrgIdIsNull() + { + var dsn = Dsn.Parse("https://key@oabc.ingest.us.sentry.io/123"); + Assert.Null(dsn.OrgId); + } + + [Fact] + public void Parse_StandardValidDsn_NoOrgId() + { + var dsn = Dsn.Parse(ValidDsn); + Assert.Null(dsn.OrgId); + } + private static void AssertEqual(DsnTestCase @case, Dsn dsn) { Assert.NotNull(@case); From 32ab7ace3acf2a57026ff44f4762b6e813ceeba9 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:32:32 +0100 Subject: [PATCH 2/6] fix: Add changelog entry, fix nullable annotation, add org ID validation to CreateFromHeaders - Add CHANGELOG.md entry for strict trace continuation feature (#4981) - Fix CS8632 build error by adding #nullable enable before test methods using string? parameters in HubTests.cs - Add org ID mismatch validation directly in SentryPropagationContext.CreateFromHeaders so it starts a new trace when SDK and baggage org IDs don't match - Pass effective org ID from Hub.ContinueTrace to CreateFromHeaders - Add CreateFromHeaders_WithOrgMismatch_StartsNewTrace and CreateFromHeaders_WithOrgMatch_ContinuesTrace tests Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 4 ++ src/Sentry/Internal/Hub.cs | 2 +- src/Sentry/SentryPropagationContext.cs | 15 ++++++- test/Sentry.Tests/HubTests.cs | 1 + .../SentryPropagationContextTests.cs | 44 +++++++++++++++++++ 5 files changed, 64 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5620d375e..44d54391d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,10 @@ - Outbound HTTP requests now show in the Network tab for Android Session Replays ([#4860](https://github.com/getsentry/sentry-dotnet/pull/4860)) +### Features + +- Add strict trace continuation support ([#4981](https://github.com/getsentry/sentry-dotnet/pull/4981)) + ### Fixes - The SDK now logs a `Warning` instead of an `Error` when being ratelimited ([#4927](https://github.com/getsentry/sentry-dotnet/pull/4927)) diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index dade93b89f..f6492a2f7e 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -385,7 +385,7 @@ public TransactionContext ContinueTrace( baggageHeader = null; } - var propagationContext = SentryPropagationContext.CreateFromHeaders(_options.DiagnosticLogger, traceHeader, baggageHeader, _replaySession); + var propagationContext = SentryPropagationContext.CreateFromHeaders(_options.DiagnosticLogger, traceHeader, baggageHeader, _replaySession, _options.GetEffectiveOrgId()); ConfigureScope(static (scope, propagationContext) => scope.SetPropagationContext(propagationContext), propagationContext); return new TransactionContext( diff --git a/src/Sentry/SentryPropagationContext.cs b/src/Sentry/SentryPropagationContext.cs index 38870215ca..a5f3c69a84 100644 --- a/src/Sentry/SentryPropagationContext.cs +++ b/src/Sentry/SentryPropagationContext.cs @@ -48,7 +48,7 @@ public SentryPropagationContext(SentryPropagationContext? other) DynamicSamplingContext = other?.DynamicSamplingContext; } - public static SentryPropagationContext CreateFromHeaders(IDiagnosticLogger? logger, SentryTraceHeader? traceHeader, BaggageHeader? baggageHeader, IReplaySession replaySession) + public static SentryPropagationContext CreateFromHeaders(IDiagnosticLogger? logger, SentryTraceHeader? traceHeader, BaggageHeader? baggageHeader, IReplaySession replaySession, string? sdkOrgId = null) { logger?.LogDebug("Creating a propagation context from headers."); @@ -58,6 +58,19 @@ public static SentryPropagationContext CreateFromHeaders(IDiagnosticLogger? logg return new SentryPropagationContext(); } + // Check for org ID mismatch between SDK configuration and incoming baggage + if (!string.IsNullOrEmpty(sdkOrgId) && baggageHeader is not null) + { + var sentryMembers = baggageHeader.GetSentryMembers(); + if (sentryMembers.TryGetValue("org_id", out var baggageOrgId) + && !string.IsNullOrEmpty(baggageOrgId) + && sdkOrgId != baggageOrgId) + { + logger?.LogInfo("Org ID mismatch (SDK: {0}, baggage: {1}). Starting new trace.", sdkOrgId, baggageOrgId); + return new SentryPropagationContext(); + } + } + var dynamicSamplingContext = baggageHeader?.CreateDynamicSamplingContext(replaySession); return new SentryPropagationContext(traceHeader.TraceId, traceHeader.SpanId, dynamicSamplingContext); } diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index 352b591df2..c669a97060 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -2808,6 +2808,7 @@ public void Dispose_CalledMultipleTimes_CleanupCalledOnlyOnce() integration.Disposed.Should().Be(1); } +#nullable enable [Theory] // strict=false: matching org IDs -> continue [InlineData(false, "1", "1", true)] diff --git a/test/Sentry.Tests/SentryPropagationContextTests.cs b/test/Sentry.Tests/SentryPropagationContextTests.cs index fa307db89a..06d85df3e6 100644 --- a/test/Sentry.Tests/SentryPropagationContextTests.cs +++ b/test/Sentry.Tests/SentryPropagationContextTests.cs @@ -161,4 +161,48 @@ public void CreateFromHeaders_BaggageHeaderNotNull_CreatesPropagationContextWith Assert.Equal("bfd31b89a59d41c99d96dc2baf840ecd", Assert.Contains("replay_id", dsc.Items)); } } + + [Fact] + public void CreateFromHeaders_WithOrgMismatch_StartsNewTrace() + { + // Arrange + var incomingTraceId = SentryId.Create(); + var traceHeader = new SentryTraceHeader(incomingTraceId, SpanId.Create(), null); + var baggageHeader = BaggageHeader.Create(new List> + { + { "sentry-trace_id", incomingTraceId.ToString() }, + { "sentry-public_key", "d4d82fc1c2c4032a83f3a29aa3a3aff" }, + { "sentry-org_id", "2" } + }); + + // Act - SDK org ID is "1", but baggage has org_id "2" + var propagationContext = SentryPropagationContext.CreateFromHeaders( + null, traceHeader, baggageHeader, _fixture.InactiveReplaySession, sdkOrgId: "1"); + + // Assert - should start a new trace because org IDs don't match + Assert.NotEqual(incomingTraceId, propagationContext.TraceId); + Assert.Null(propagationContext.ParentSpanId); + Assert.Null(propagationContext._dynamicSamplingContext); + } + + [Fact] + public void CreateFromHeaders_WithOrgMatch_ContinuesTrace() + { + // Arrange + var incomingTraceId = SentryId.Create(); + var traceHeader = new SentryTraceHeader(incomingTraceId, SpanId.Create(), null); + var baggageHeader = BaggageHeader.Create(new List> + { + { "sentry-trace_id", incomingTraceId.ToString() }, + { "sentry-public_key", "d4d82fc1c2c4032a83f3a29aa3a3aff" }, + { "sentry-org_id", "1" } + }); + + // Act - SDK org ID matches baggage org_id + var propagationContext = SentryPropagationContext.CreateFromHeaders( + null, traceHeader, baggageHeader, _fixture.InactiveReplaySession, sdkOrgId: "1"); + + // Assert - should continue the trace + Assert.Equal(incomingTraceId, propagationContext.TraceId); + } } From d733ff13e04bc2623d586de433c380900e930e03 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 19 Mar 2026 16:21:23 +1300 Subject: [PATCH 3/6] Fix bindable options --- src/Sentry/BindableSentryOptions.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Sentry/BindableSentryOptions.cs b/src/Sentry/BindableSentryOptions.cs index e1210878d4..6815aa0d95 100644 --- a/src/Sentry/BindableSentryOptions.cs +++ b/src/Sentry/BindableSentryOptions.cs @@ -46,6 +46,8 @@ internal partial class BindableSentryOptions public double? TracesSampleRate { get; set; } public List? TracePropagationTargets { get; set; } public bool? PropagateTraceparent { get; set; } + public bool? StrictTraceContinuation { get; set; } + public string? OrgId { get; set; } public double? ProfilesSampleRate { get; set; } public StackTraceMode? StackTraceMode { get; set; } public long? MaxAttachmentSize { get; set; } @@ -99,6 +101,8 @@ public void ApplyTo(SentryOptions options) options.ProfilesSampleRate = ProfilesSampleRate ?? options.ProfilesSampleRate; options.TracePropagationTargets = TracePropagationTargets?.Select(s => new StringOrRegex(s)).ToList() ?? options.TracePropagationTargets; options.PropagateTraceparent = PropagateTraceparent ?? options.PropagateTraceparent; + options.StrictTraceContinuation = StrictTraceContinuation ?? options.StrictTraceContinuation; + options.OrgId = OrgId ?? options.OrgId; options.StackTraceMode = StackTraceMode ?? options.StackTraceMode; options.MaxAttachmentSize = MaxAttachmentSize ?? options.MaxAttachmentSize; options.DetectStartupTime = DetectStartupTime ?? options.DetectStartupTime; From 95161f0983f76f555bf3f17e4dfc3b02ae856273 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 7 May 2026 15:10:54 +1200 Subject: [PATCH 4/6] fix: Remove dead org-ID check from CreateFromHeaders; add DSC org_id tests Hub.ShouldContinueTrace already nulls both headers before calling CreateFromHeaders, making the org-ID guard inside CreateFromHeaders unreachable from all current call paths. Remove the duplicate check and its sdkOrgId parameter to eliminate the inconsistency and maintenance risk. Add ValidDsnWithOrgId DSN sample and six new DynamicSamplingContextTests covering that org_id is included in outgoing baggage for all three factory methods (CreateFromTransaction, CreateFromUnsampledTransaction, CreateFromPropagationContext) when the DSN has an o host prefix, and excluded when it does not. Co-Authored-By: Claude Sonnet 4.6 --- src/Sentry/Internal/Hub.cs | 2 +- src/Sentry/SentryPropagationContext.cs | 15 +-- test/Sentry.Testing/DsnSamples.cs | 5 + .../DynamicSamplingContextTests.cs | 116 ++++++++++++++++++ .../SentryPropagationContextTests.cs | 43 ------- 5 files changed, 123 insertions(+), 58 deletions(-) diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index f6492a2f7e..dade93b89f 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -385,7 +385,7 @@ public TransactionContext ContinueTrace( baggageHeader = null; } - var propagationContext = SentryPropagationContext.CreateFromHeaders(_options.DiagnosticLogger, traceHeader, baggageHeader, _replaySession, _options.GetEffectiveOrgId()); + var propagationContext = SentryPropagationContext.CreateFromHeaders(_options.DiagnosticLogger, traceHeader, baggageHeader, _replaySession); ConfigureScope(static (scope, propagationContext) => scope.SetPropagationContext(propagationContext), propagationContext); return new TransactionContext( diff --git a/src/Sentry/SentryPropagationContext.cs b/src/Sentry/SentryPropagationContext.cs index a5f3c69a84..38870215ca 100644 --- a/src/Sentry/SentryPropagationContext.cs +++ b/src/Sentry/SentryPropagationContext.cs @@ -48,7 +48,7 @@ public SentryPropagationContext(SentryPropagationContext? other) DynamicSamplingContext = other?.DynamicSamplingContext; } - public static SentryPropagationContext CreateFromHeaders(IDiagnosticLogger? logger, SentryTraceHeader? traceHeader, BaggageHeader? baggageHeader, IReplaySession replaySession, string? sdkOrgId = null) + public static SentryPropagationContext CreateFromHeaders(IDiagnosticLogger? logger, SentryTraceHeader? traceHeader, BaggageHeader? baggageHeader, IReplaySession replaySession) { logger?.LogDebug("Creating a propagation context from headers."); @@ -58,19 +58,6 @@ public static SentryPropagationContext CreateFromHeaders(IDiagnosticLogger? logg return new SentryPropagationContext(); } - // Check for org ID mismatch between SDK configuration and incoming baggage - if (!string.IsNullOrEmpty(sdkOrgId) && baggageHeader is not null) - { - var sentryMembers = baggageHeader.GetSentryMembers(); - if (sentryMembers.TryGetValue("org_id", out var baggageOrgId) - && !string.IsNullOrEmpty(baggageOrgId) - && sdkOrgId != baggageOrgId) - { - logger?.LogInfo("Org ID mismatch (SDK: {0}, baggage: {1}). Starting new trace.", sdkOrgId, baggageOrgId); - return new SentryPropagationContext(); - } - } - var dynamicSamplingContext = baggageHeader?.CreateDynamicSamplingContext(replaySession); return new SentryPropagationContext(traceHeader.TraceId, traceHeader.SpanId, dynamicSamplingContext); } diff --git a/test/Sentry.Testing/DsnSamples.cs b/test/Sentry.Testing/DsnSamples.cs index 6ad50d1598..cb6be358f1 100644 --- a/test/Sentry.Testing/DsnSamples.cs +++ b/test/Sentry.Testing/DsnSamples.cs @@ -8,6 +8,11 @@ public static class DsnSamples /// public const string ValidDsn = "https://d4d82fc1c2c4032a83f3a29aa3a3aff@fake-sentry.io:65535/2147483647"; + /// + /// A DSN whose host has an org-ID prefix (e.g. o123.ingest.us.sentry.io), causing OrgId to be parsed as "123". + /// + public const string ValidDsnWithOrgId = "https://d4d82fc1c2c4032a83f3a29aa3a3aff@o123.ingest.us.sentry.io/456"; + /// /// Missing ProjectId /// diff --git a/test/Sentry.Tests/DynamicSamplingContextTests.cs b/test/Sentry.Tests/DynamicSamplingContextTests.cs index bcc9329e24..c62a8fcc28 100644 --- a/test/Sentry.Tests/DynamicSamplingContextTests.cs +++ b/test/Sentry.Tests/DynamicSamplingContextTests.cs @@ -462,6 +462,122 @@ public void CreateFromUnsampledTransaction() Assert.Equal(_fixture.ActiveReplayId.ToString(), Assert.Contains("replay_id", dsc.Items)); } + [Fact] + public void CreateFromTransaction_WithOrgIdDsn_IncludesOrgId() + { + var options = new SentryOptions + { + Dsn = ValidDsnWithOrgId, + Release = "foo@1.0.0", + Environment = "production" + }; + + var hub = Substitute.For(); + var ctx = Substitute.For(); + ctx.TraceId.Returns(SentryId.Create()); + + var transaction = new TransactionTracer(hub, ctx) + { + Name = "GET /test", + NameSource = TransactionNameSource.Route, + SampleRate = 1.0, + SampleRand = 0.5, + }; + + var dsc = transaction.CreateDynamicSamplingContext(options, _fixture.InactiveReplaySession); + + Assert.Equal("123", Assert.Contains("org_id", dsc.Items)); + } + + [Fact] + public void CreateFromTransaction_WithoutOrgIdDsn_ExcludesOrgId() + { + var options = new SentryOptions { Dsn = ValidDsn }; + + var hub = Substitute.For(); + var ctx = Substitute.For(); + ctx.TraceId.Returns(SentryId.Create()); + + var transaction = new TransactionTracer(hub, ctx) + { + Name = "GET /test", + NameSource = TransactionNameSource.Route, + SampleRate = 1.0, + SampleRand = 0.5, + }; + + var dsc = transaction.CreateDynamicSamplingContext(options, _fixture.InactiveReplaySession); + + Assert.DoesNotContain("org_id", dsc.Items); + } + + [Fact] + public void CreateFromUnsampledTransaction_WithOrgIdDsn_IncludesOrgId() + { + var options = new SentryOptions + { + Dsn = ValidDsnWithOrgId, + Release = "foo@1.0.0", + Environment = "production" + }; + + var hub = Substitute.For(); + var ctx = Substitute.For(); + ctx.TraceId.Returns(SentryId.Create()); + + var transaction = new UnsampledTransaction(hub, ctx) + { + SampleRate = 1.0, + SampleRand = 0.5, + }; + + var dsc = transaction.CreateDynamicSamplingContext(options, _fixture.InactiveReplaySession); + + Assert.Equal("123", Assert.Contains("org_id", dsc.Items)); + } + + [Fact] + public void CreateFromUnsampledTransaction_WithoutOrgIdDsn_ExcludesOrgId() + { + var options = new SentryOptions { Dsn = ValidDsn }; + + var hub = Substitute.For(); + var ctx = Substitute.For(); + ctx.TraceId.Returns(SentryId.Create()); + + var transaction = new UnsampledTransaction(hub, ctx) + { + SampleRate = 1.0, + SampleRand = 0.5, + }; + + var dsc = transaction.CreateDynamicSamplingContext(options, _fixture.InactiveReplaySession); + + Assert.DoesNotContain("org_id", dsc.Items); + } + + [Fact] + public void CreateFromPropagationContext_WithOrgIdDsn_IncludesOrgId() + { + var options = new SentryOptions { Dsn = ValidDsnWithOrgId }; + var propagationContext = new SentryPropagationContext(SentryId.Create(), SpanId.Create()); + + var dsc = propagationContext.CreateDynamicSamplingContext(options, _fixture.InactiveReplaySession); + + Assert.Equal("123", Assert.Contains("org_id", dsc.Items)); + } + + [Fact] + public void CreateFromPropagationContext_WithoutOrgIdDsn_ExcludesOrgId() + { + var options = new SentryOptions { Dsn = "https://a@sentry.io/1" }; + var propagationContext = new SentryPropagationContext(SentryId.Create(), SpanId.Create()); + + var dsc = propagationContext.CreateDynamicSamplingContext(options, _fixture.InactiveReplaySession); + + Assert.DoesNotContain("org_id", dsc.Items); + } + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/test/Sentry.Tests/SentryPropagationContextTests.cs b/test/Sentry.Tests/SentryPropagationContextTests.cs index 06d85df3e6..01f2e3e577 100644 --- a/test/Sentry.Tests/SentryPropagationContextTests.cs +++ b/test/Sentry.Tests/SentryPropagationContextTests.cs @@ -162,47 +162,4 @@ public void CreateFromHeaders_BaggageHeaderNotNull_CreatesPropagationContextWith } } - [Fact] - public void CreateFromHeaders_WithOrgMismatch_StartsNewTrace() - { - // Arrange - var incomingTraceId = SentryId.Create(); - var traceHeader = new SentryTraceHeader(incomingTraceId, SpanId.Create(), null); - var baggageHeader = BaggageHeader.Create(new List> - { - { "sentry-trace_id", incomingTraceId.ToString() }, - { "sentry-public_key", "d4d82fc1c2c4032a83f3a29aa3a3aff" }, - { "sentry-org_id", "2" } - }); - - // Act - SDK org ID is "1", but baggage has org_id "2" - var propagationContext = SentryPropagationContext.CreateFromHeaders( - null, traceHeader, baggageHeader, _fixture.InactiveReplaySession, sdkOrgId: "1"); - - // Assert - should start a new trace because org IDs don't match - Assert.NotEqual(incomingTraceId, propagationContext.TraceId); - Assert.Null(propagationContext.ParentSpanId); - Assert.Null(propagationContext._dynamicSamplingContext); - } - - [Fact] - public void CreateFromHeaders_WithOrgMatch_ContinuesTrace() - { - // Arrange - var incomingTraceId = SentryId.Create(); - var traceHeader = new SentryTraceHeader(incomingTraceId, SpanId.Create(), null); - var baggageHeader = BaggageHeader.Create(new List> - { - { "sentry-trace_id", incomingTraceId.ToString() }, - { "sentry-public_key", "d4d82fc1c2c4032a83f3a29aa3a3aff" }, - { "sentry-org_id", "1" } - }); - - // Act - SDK org ID matches baggage org_id - var propagationContext = SentryPropagationContext.CreateFromHeaders( - null, traceHeader, baggageHeader, _fixture.InactiveReplaySession, sdkOrgId: "1"); - - // Assert - should continue the trace - Assert.Equal(incomingTraceId, propagationContext.TraceId); - } } From ce0127d5a3ba4c050e1eabf486f060de3e7a5bac Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 7 May 2026 17:01:38 +1200 Subject: [PATCH 5/6] fix: Add orgId to CreateFromExternalPropagationContext; add tests CreateFromExternalPropagationContext (the OTel path) was missing orgId: options.GetEffectiveOrgId(), which would cause downstream services with StrictTraceContinuation to start new traces unnecessarily. Add two DynamicSamplingContextTests covering that org_id is included when the DSN has an o host prefix and excluded when it does not. Co-Authored-By: Claude Sonnet 4.6 --- src/Sentry/DynamicSamplingContext.cs | 3 +- .../DynamicSamplingContextTests.cs | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/Sentry/DynamicSamplingContext.cs b/src/Sentry/DynamicSamplingContext.cs index d020d618d1..34079d68ab 100644 --- a/src/Sentry/DynamicSamplingContext.cs +++ b/src/Sentry/DynamicSamplingContext.cs @@ -272,7 +272,8 @@ public static DynamicSamplingContext CreateFromPropagationContext(SentryPropagat propagationContext.SampleRand, release: release, environment: environment, - replaySession: replaySession + replaySession: replaySession, + orgId: options.GetEffectiveOrgId() ); } } diff --git a/test/Sentry.Tests/DynamicSamplingContextTests.cs b/test/Sentry.Tests/DynamicSamplingContextTests.cs index c62a8fcc28..db7f49f070 100644 --- a/test/Sentry.Tests/DynamicSamplingContextTests.cs +++ b/test/Sentry.Tests/DynamicSamplingContextTests.cs @@ -604,4 +604,38 @@ public void CreateFromPropagationContext_Valid_Complete(bool replaySessionIsActi Assert.DoesNotContain("replay_id", dsc.Items); } } + + [Fact] + public void CreateFromExternalPropagationContext_WithOrgIdDsn_IncludesOrgId() + { + var options = new SentryOptions { Dsn = ValidDsnWithOrgId }; + + var propagationContext = Substitute.For(); + propagationContext.TraceId.Returns(SentryId.Create()); + propagationContext.IsSampled.Returns(true); + propagationContext.SampleRate.Returns(1.0); + propagationContext.SampleRand.Returns(0.5); + + var dsc = DynamicSamplingContext.CreateFromExternalPropagationContext(propagationContext, options, _fixture.InactiveReplaySession); + + Assert.NotNull(dsc); + Assert.Equal("123", Assert.Contains("org_id", dsc.Items)); + } + + [Fact] + public void CreateFromExternalPropagationContext_WithoutOrgIdDsn_ExcludesOrgId() + { + var options = new SentryOptions { Dsn = ValidDsn }; + + var propagationContext = Substitute.For(); + propagationContext.TraceId.Returns(SentryId.Create()); + propagationContext.IsSampled.Returns(true); + propagationContext.SampleRate.Returns(1.0); + propagationContext.SampleRand.Returns(0.5); + + var dsc = DynamicSamplingContext.CreateFromExternalPropagationContext(propagationContext, options, _fixture.InactiveReplaySession); + + Assert.NotNull(dsc); + Assert.DoesNotContain("org_id", dsc.Items); + } } From ebb0957cd2b287c2154ec2e8795083cc87f5a86c Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Thu, 7 May 2026 19:49:34 +1200 Subject: [PATCH 6/6] Apply suggestion from @jamescrosswell --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d54391d6..f5620d375e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,10 +120,6 @@ - Outbound HTTP requests now show in the Network tab for Android Session Replays ([#4860](https://github.com/getsentry/sentry-dotnet/pull/4860)) -### Features - -- Add strict trace continuation support ([#4981](https://github.com/getsentry/sentry-dotnet/pull/4981)) - ### Fixes - The SDK now logs a `Warning` instead of an `Error` when being ratelimited ([#4927](https://github.com/getsentry/sentry-dotnet/pull/4927))