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; 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..34079d68ab 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() ); } @@ -263,7 +272,8 @@ public static DynamicSamplingContext CreateFromPropagationContext(SentryPropagat propagationContext.SampleRand, 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.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/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/DynamicSamplingContextTests.cs b/test/Sentry.Tests/DynamicSamplingContextTests.cs index bcc9329e24..db7f49f070 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)] @@ -488,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); + } } diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index 9d8feed513..c669a97060 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -2807,6 +2807,126 @@ public void Dispose_CalledMultipleTimes_CleanupCalledOnlyOnce() // Assert integration.Disposed.Should().Be(1); } + +#nullable enable + [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); diff --git a/test/Sentry.Tests/SentryPropagationContextTests.cs b/test/Sentry.Tests/SentryPropagationContextTests.cs index fa307db89a..01f2e3e577 100644 --- a/test/Sentry.Tests/SentryPropagationContextTests.cs +++ b/test/Sentry.Tests/SentryPropagationContextTests.cs @@ -161,4 +161,5 @@ public void CreateFromHeaders_BaggageHeaderNotNull_CreatesPropagationContextWith Assert.Equal("bfd31b89a59d41c99d96dc2baf840ecd", Assert.Contains("replay_id", dsc.Items)); } } + }