diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d6a15ac40..f53a8dea7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### 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/Dsn.cs b/src/Sentry/Dsn.cs index 011af39169..6a1d9934c1 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/"); @@ -93,6 +101,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, @@ -107,7 +128,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 f39d7fa5a7..61f0e6aa5e 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.GetEffectiveOrgId()); } 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.GetEffectiveOrgId()); } 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.GetEffectiveOrgId() ); } } diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index be71b2f1d3..9eb32facda 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -355,7 +355,14 @@ public TransactionContext ContinueTrace( string? name = null, string? operation = null) { - var propagationContext = SentryPropagationContext.CreateFromHeaders(_options.DiagnosticLogger, traceHeader, baggageHeader, _replaySession); + 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, _options.GetEffectiveOrgId()); ConfigureScope(static (scope, propagationContext) => scope.SetPropagationContext(propagationContext), propagationContext); return new TransactionContext( @@ -368,6 +375,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 7fc69a600f..7e4f8bdc29 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -448,6 +448,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) => @@ -1032,6 +1050,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/src/Sentry/SentryPropagationContext.cs b/src/Sentry/SentryPropagationContext.cs index 6183262241..a19d8f7b39 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/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 64869d4587..0c8b3542b7 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -823,6 +823,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; } @@ -838,6 +839,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 TracePropagationTargets { get; set; } public double? TracesSampleRate { 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 64869d4587..0c8b3542b7 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -823,6 +823,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; } @@ -838,6 +839,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 TracePropagationTargets { get; set; } public double? TracesSampleRate { 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 64869d4587..0c8b3542b7 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -823,6 +823,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; } @@ -838,6 +839,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 TracePropagationTargets { get; set; } public double? TracesSampleRate { 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 fbf517dd1e..7bfe25a6a4 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -809,6 +809,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; } @@ -824,6 +825,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 TracePropagationTargets { get; set; } public double? TracesSampleRate { get; set; } diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index c8bb6ea702..a61632ac38 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -2662,6 +2662,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 41e91b702e..b95b2171ec 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) { if (@case == null) diff --git a/test/Sentry.Tests/SentryPropagationContextTests.cs b/test/Sentry.Tests/SentryPropagationContextTests.cs index bb90359d0a..6c90e20a48 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); + } }