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;