From 834b84df44e14ee7867a06db51279ed541bb9a3b Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:10:33 +0100 Subject: [PATCH 1/4] feat: Add strict trace continuation support Extract org ID from DSN host, add StrictTraceContinuation and OrgId options, propagate org_id in Dynamic Sampling Context, and validate incoming traces per the decision matrix. Closes #4963 --- src/Sentry/Dsn.cs | 24 +++++- src/Sentry/DynamicSamplingContext.cs | 17 ++++- src/Sentry/Internal/Hub.cs | 2 +- src/Sentry/SentryOptions.cs | 18 +++++ src/Sentry/SentryPropagationContext.cs | 41 +++++++++++ test/Sentry.Tests/Protocol/DsnTests.cs | 28 +++++++ .../SentryPropagationContextTests.cs | 73 +++++++++++++++++++ 7 files changed, 196 insertions(+), 7 deletions(-) diff --git a/src/Sentry/Dsn.cs b/src/Sentry/Dsn.cs index 011af39169..15084438df 100644 --- a/src/Sentry/Dsn.cs +++ b/src/Sentry/Dsn.cs @@ -1,3 +1,5 @@ +using System.Text.RegularExpressions; + namespace Sentry; /// @@ -8,6 +10,8 @@ namespace Sentry; /// internal sealed class Dsn { + private static readonly Regex OrgIdRegex = new(@"^o(\d+)\.", RegexOptions.Compiled); + /// /// Source DSN string. /// @@ -33,6 +37,11 @@ internal sealed class Dsn /// public string PublicKey { get; } + /// + /// The organization ID extracted from the DSN host. + /// + public string? OrgId { get; } + /// /// Sentry API's base URI. /// @@ -44,7 +53,8 @@ private Dsn( string? path, string? secretKey, string publicKey, - Uri apiBaseUri) + Uri apiBaseUri, + string? orgId = null) { Source = source; ProjectId = projectId; @@ -52,6 +62,7 @@ private Dsn( SecretKey = secretKey; PublicKey = publicKey; ApiBaseUri = apiBaseUri; + OrgId = orgId; } public Uri GetStoreEndpointUri() => new(ApiBaseUri, "store/"); @@ -101,13 +112,22 @@ public static Dsn Parse(string dsn) Path = $"{path}/api/{projectId}/" }.Uri; + // Extract org ID from host (e.g., "o123.ingest.sentry.io" -> "123") + string? orgId = null; + var orgMatch = OrgIdRegex.Match(uri.DnsSafeHost); + if (orgMatch.Success) + { + orgId = orgMatch.Groups[1].Value; + } + return new Dsn( dsn, projectId, 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 f39d7fa5a7..ec5f35cbc6 100644 --- a/src/Sentry/DynamicSamplingContext.cs +++ b/src/Sentry/DynamicSamplingContext.cs @@ -30,7 +30,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) @@ -95,6 +96,11 @@ private DynamicSamplingContext(SentryId traceId, items.Add("replay_id", replayId.ToString()); } + if (!string.IsNullOrWhiteSpace(orgId)) + { + items.Add("org_id", orgId); + } + _items = items; } @@ -200,7 +206,8 @@ public static DynamicSamplingContext CreateFromTransaction(TransactionTracer tra release, environment, transactionName, - replaySession); + replaySession, + orgId: options.EffectiveOrgId); } public static DynamicSamplingContext CreateFromUnsampledTransaction(UnsampledTransaction transaction, SentryOptions options, IReplaySession? replaySession) @@ -225,7 +232,8 @@ public static DynamicSamplingContext CreateFromUnsampledTransaction(UnsampledTra release, environment, transactionName, - replaySession); + replaySession, + orgId: options.EffectiveOrgId); } public static DynamicSamplingContext CreateFromPropagationContext(SentryPropagationContext propagationContext, SentryOptions options, IReplaySession? replaySession) @@ -241,7 +249,8 @@ public static DynamicSamplingContext CreateFromPropagationContext(SentryPropagat null, release: release, environment: environment, - replaySession: replaySession + replaySession: replaySession, + orgId: options.EffectiveOrgId ); } } diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index be71b2f1d3..9a4daaaadc 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -355,7 +355,7 @@ public TransactionContext ContinueTrace( string? name = null, string? operation = null) { - var propagationContext = SentryPropagationContext.CreateFromHeaders(_options.DiagnosticLogger, traceHeader, baggageHeader, _replaySession); + var propagationContext = SentryPropagationContext.CreateFromHeaders(_options.DiagnosticLogger, traceHeader, baggageHeader, _replaySession, _options); ConfigureScope(static (scope, propagationContext) => scope.SetPropagationContext(propagationContext), propagationContext); return new TransactionContext( diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index 7fc69a600f..5836c1f5bf 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -1032,6 +1032,24 @@ public IList TracePropagationTargets /// public bool PropagateTraceparent { get; set; } + /// + /// Controls whether the SDK requires matching org IDs from incoming baggage to continue a trace. + /// When true, both the SDK's org ID and the incoming baggage org ID must be present and match. + /// Default is false. + /// + public bool StrictTraceContinuation { get; set; } + + /// + /// An optional organization ID. The SDK will try to extract it from the DSN in most cases + /// but you can provide it explicitly for self-hosted and Relay setups. + /// + public string? OrgId { get; set; } + + /// + /// Returns the effective org ID, preferring the explicit config option over the DSN-parsed value. + /// + internal string? EffectiveOrgId => OrgId ?? _parsedDsn?.OrgId; + internal ITransactionProfilerFactory? TransactionProfilerFactory { get; set; } private StackTraceMode? _stackTraceMode; diff --git a/src/Sentry/SentryPropagationContext.cs b/src/Sentry/SentryPropagationContext.cs index 6183262241..6d56f66daa 100644 --- a/src/Sentry/SentryPropagationContext.cs +++ b/src/Sentry/SentryPropagationContext.cs @@ -49,6 +49,11 @@ public SentryPropagationContext(SentryPropagationContext? other) } public static SentryPropagationContext CreateFromHeaders(IDiagnosticLogger? logger, SentryTraceHeader? traceHeader, BaggageHeader? baggageHeader, IReplaySession replaySession) + { + return CreateFromHeaders(logger, traceHeader, baggageHeader, replaySession, null); + } + + public static SentryPropagationContext CreateFromHeaders(IDiagnosticLogger? logger, SentryTraceHeader? traceHeader, BaggageHeader? baggageHeader, IReplaySession replaySession, SentryOptions? options) { logger?.LogDebug("Creating a propagation context from headers."); @@ -58,7 +63,43 @@ public static SentryPropagationContext CreateFromHeaders(IDiagnosticLogger? logg return new SentryPropagationContext(); } + if (options != null && !ShouldContinueTrace(options, baggageHeader)) + { + logger?.LogDebug("Not continuing trace due to org ID mismatch."); + return new SentryPropagationContext(); + } + var dynamicSamplingContext = baggageHeader?.CreateDynamicSamplingContext(replaySession); return new SentryPropagationContext(traceHeader.TraceId, traceHeader.SpanId, dynamicSamplingContext); } + + internal static bool ShouldContinueTrace(SentryOptions options, BaggageHeader? baggageHeader) + { + var sdkOrgId = options.EffectiveOrgId; + + string? baggageOrgId = null; + if (baggageHeader != null) + { + var sentryMembers = baggageHeader.GetSentryMembers(); + sentryMembers.TryGetValue("org_id", out baggageOrgId); + } + + // Mismatched org IDs always reject regardless of strict mode + if (sdkOrgId != null && baggageOrgId != null && sdkOrgId != baggageOrgId) + { + return false; + } + + // In strict mode, both must be present and match (unless both are missing) + if (options.StrictTraceContinuation) + { + if (sdkOrgId == null && baggageOrgId == null) + { + return true; + } + return sdkOrgId != null && sdkOrgId == baggageOrgId; + } + + return true; + } } diff --git a/test/Sentry.Tests/Protocol/DsnTests.cs b/test/Sentry.Tests/Protocol/DsnTests.cs index 41e91b702e..bdba814c51 100644 --- a/test/Sentry.Tests/Protocol/DsnTests.cs +++ b/test/Sentry.Tests/Protocol/DsnTests.cs @@ -301,4 +301,32 @@ private static void AssertEqual(DsnTestCase @case, Dsn dsn) Assert.Equal(@case, uri); } + + [Fact] + public void Parse_OrgIdFromHost() + { + var dsn = Dsn.Parse("https://key@o123.ingest.sentry.io/456"); + Assert.Equal("123", dsn.OrgId); + } + + [Fact] + public void Parse_SingleDigitOrgId() + { + var dsn = Dsn.Parse("https://key@o1.ingest.us.sentry.io/456"); + Assert.Equal("1", dsn.OrgId); + } + + [Fact] + public void Parse_NoOrgIdInHost() + { + var dsn = Dsn.Parse("https://key@sentry.io/456"); + Assert.Null(dsn.OrgId); + } + + [Fact] + public void Parse_NonStandardHost_NoOrgId() + { + var dsn = Dsn.Parse("http://key@localhost:9000/456"); + Assert.Null(dsn.OrgId); + } } diff --git a/test/Sentry.Tests/SentryPropagationContextTests.cs b/test/Sentry.Tests/SentryPropagationContextTests.cs index bb90359d0a..6b0a0ff0bc 100644 --- a/test/Sentry.Tests/SentryPropagationContextTests.cs +++ b/test/Sentry.Tests/SentryPropagationContextTests.cs @@ -161,4 +161,77 @@ public void CreateFromHeaders_BaggageHeaderNotNull_CreatesPropagationContextWith Assert.Equal("bfd31b89a59d41c99d96dc2baf840ecd", Assert.Contains("replay_id", dsc.Items)); } } + + // Decision matrix tests for ShouldContinueTrace + + [Theory] + // strict=false cases + [InlineData("1", "1", false, true)] // matching orgs → continue + [InlineData(null, "1", false, true)] // baggage missing → continue + [InlineData("1", null, false, true)] // SDK missing → continue + [InlineData(null, null, false, true)] // both missing → continue + [InlineData("1", "2", false, false)] // mismatch → new trace + // strict=true cases + [InlineData("1", "1", true, true)] // matching orgs → continue + [InlineData(null, "1", true, false)] // baggage missing → new trace + [InlineData("1", null, true, false)] // SDK missing → new trace + [InlineData(null, null, true, true)] // both missing → continue + [InlineData("1", "2", true, false)] // mismatch → new trace + public void ShouldContinueTrace_DecisionMatrix(string? baggageOrgId, string? sdkOrgId, bool strict, bool expected) + { + var options = new SentryOptions + { + Dsn = ValidDsn, + StrictTraceContinuation = strict + }; + if (sdkOrgId != null) + { + options.OrgId = sdkOrgId; + } + + BaggageHeader? baggage = null; + if (baggageOrgId != null) + { + baggage = BaggageHeader.TryParse($"sentry-trace_id=bc6d53f15eb88f4320054569b8c553d4,sentry-org_id={baggageOrgId}"); + } + else + { + baggage = BaggageHeader.TryParse("sentry-trace_id=bc6d53f15eb88f4320054569b8c553d4"); + } + + var result = SentryPropagationContext.ShouldContinueTrace(options, baggage); + Assert.Equal(expected, result); + } + + [Fact] + public void CreateFromHeaders_WithOrgMismatch_StartsNewTrace() + { + var options = new SentryOptions + { + Dsn = "https://key@o2.ingest.sentry.io/456", + StrictTraceContinuation = false + }; + + var traceHeader = SentryTraceHeader.Parse("bc6d53f15eb88f4320054569b8c553d4-b72fa28504b07285-1"); + var baggage = BaggageHeader.TryParse("sentry-trace_id=bc6d53f15eb88f4320054569b8c553d4,sentry-org_id=1"); + + var propagationContext = SentryPropagationContext.CreateFromHeaders(null, traceHeader, baggage, _fixture.InactiveReplaySession, options); + Assert.NotEqual(SentryId.Parse("bc6d53f15eb88f4320054569b8c553d4"), propagationContext.TraceId); + } + + [Fact] + public void CreateFromHeaders_WithOrgMatch_ContinuesTrace() + { + var options = new SentryOptions + { + Dsn = "https://key@o1.ingest.sentry.io/456", + StrictTraceContinuation = false + }; + + var traceHeader = SentryTraceHeader.Parse("bc6d53f15eb88f4320054569b8c553d4-b72fa28504b07285-1"); + var baggage = BaggageHeader.TryParse("sentry-trace_id=bc6d53f15eb88f4320054569b8c553d4,sentry-org_id=1,sentry-public_key=key,sentry-sample_rate=1.0,sentry-sample_rand=0.5000,sentry-sampled=true"); + + var propagationContext = SentryPropagationContext.CreateFromHeaders(null, traceHeader, baggage, _fixture.InactiveReplaySession, options); + Assert.Equal(SentryId.Parse("bc6d53f15eb88f4320054569b8c553d4"), propagationContext.TraceId); + } } From 48c0d4428c60d6466fed899c9e3dc89a08bcc96b Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:59:11 +0100 Subject: [PATCH 2/4] fix: Remove nullable annotations from test file The test project does not have nullable reference types enabled, so string? and BaggageHeader? annotations cause CS8632 errors. --- test/Sentry.Tests/SentryPropagationContextTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Sentry.Tests/SentryPropagationContextTests.cs b/test/Sentry.Tests/SentryPropagationContextTests.cs index 6b0a0ff0bc..74621dcea5 100644 --- a/test/Sentry.Tests/SentryPropagationContextTests.cs +++ b/test/Sentry.Tests/SentryPropagationContextTests.cs @@ -177,7 +177,7 @@ public void CreateFromHeaders_BaggageHeaderNotNull_CreatesPropagationContextWith [InlineData("1", null, true, false)] // SDK missing → new trace [InlineData(null, null, true, true)] // both missing → continue [InlineData("1", "2", true, false)] // mismatch → new trace - public void ShouldContinueTrace_DecisionMatrix(string? baggageOrgId, string? sdkOrgId, bool strict, bool expected) + public void ShouldContinueTrace_DecisionMatrix(string baggageOrgId, string sdkOrgId, bool strict, bool expected) { var options = new SentryOptions { @@ -189,7 +189,7 @@ public void ShouldContinueTrace_DecisionMatrix(string? baggageOrgId, string? sdk options.OrgId = sdkOrgId; } - BaggageHeader? baggage = null; + BaggageHeader baggage = null; if (baggageOrgId != null) { baggage = BaggageHeader.TryParse($"sentry-trace_id=bc6d53f15eb88f4320054569b8c553d4,sentry-org_id={baggageOrgId}"); From c0f3fefff4fa559c528ecad70a0cbe77df45563d Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:01:44 +0100 Subject: [PATCH 3/4] Add changelog entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d6a15ac40..efb9bec3e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Add strict trace continuation support ([#4972](https://github.com/getsentry/sentry-dotnet/pull/4972)) + ### Fixes - The SDK now logs a `Warning` instead of an `Error` when being ratelimited ([#4927](https://github.com/getsentry/sentry-dotnet/pull/4927)) From fee48cfbbd03c72be1c08a54e97a7b0db48ce348 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:07:20 +0100 Subject: [PATCH 4/4] Add StrictTraceContinuation and OrgId to BindableSentryOptions Co-Authored-By: Claude Opus 4.6 --- src/Sentry/BindableSentryOptions.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Sentry/BindableSentryOptions.cs b/src/Sentry/BindableSentryOptions.cs index 52abdf7df9..d4a5bd5c57 100644 --- a/src/Sentry/BindableSentryOptions.cs +++ b/src/Sentry/BindableSentryOptions.cs @@ -44,6 +44,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; } @@ -97,6 +99,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;